add support heartbeat to couchdb-changes-stream

This commit is contained in:
2024-11-29 12:38:08 +02:00
parent a393a195d0
commit 9cc03623d9
27 changed files with 8998 additions and 8531 deletions
-8509
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
node_modules
dist
+144
View File
@@ -0,0 +1,144 @@
# CouchDBChangesStream Documentation
---
## Description
`CouchDBChangesStream` is a TypeScript class for handling real-time changes in a CouchDB database through the `_changes` API. It supports `normal`, `longpoll`, `continuous`, and `eventsource` modes, with filtering via `selector` and automatic reconnection in `live` mode.
---
## Installation
Install the package via npm:
```bash
npm install @hereconnect/couchdb-changes-stream
```
---
## Usage
### Example
```typescript
import { CouchDBChangesStream } from "couchdb-changes-stream";
const changesStream = new CouchDBChangesStream(
"http://localhost:5984/mydb",
{
feed: "continuous",
include_docs: true,
since: "now",
heartbeat: 10000,
selector: {
type: "message",
},
},
);
(async () => {
try {
for await (const change of changesStream) {
console.log("Change:", change);
// Process data
if (change.doc) {
console.log("Document:", change.doc);
}
}
} catch (error) {
console.error("Error in changes stream:", error);
}
})();
```
---
## Parameters
### Constructor
```typescript
new CouchDBChangesStream<T>(
dbUrl: string,
options: CouchDBChangesOptions | Readonly<CouchDBChangesOptions>,
)
```
### Constructor Parameters
| Parameter | Type | Description |
|--------------------|----------------------------------------------|-----------------------------------------------------------------------------|
| `dbUrl` | `string` | The CouchDB database URL (e.g., `http://localhost:5984/mydb`). |
| `options` | `CouchDBChangesOptions` | The query options. Supports the same parameters as CouchDB `_changes` API. |
### Fields in `CouchDBChangesOptions`
| Field | Type | Description |
|--------------------|----------------------------------------------|-----------------------------------------------------------------------------|
| `since` | `string \| number` | The sequence to start from (`now` or a number). |
| `filter` | `string` | Name of the filter (e.g., `_selector`). |
| `doc_ids` | `string[] \| readonly string[]` | List of document IDs to filter. |
| `selector` | `Record<string, unknown>` | A JSON object for selecting documents (works only with `POST`). |
| `feed` | `"normal" \| "longpoll" \| "continuous" \| "eventsource"` | Feed mode. |
| `include_docs` | `boolean` | Include document body in changes. |
| `heartbeat` | `boolean \| number` | Frequency of keep-alive messages in milliseconds. |
| `live` | `boolean` | Enable live mode with automatic reconnection. |
| `timeout` | `number` | Timeout for waiting for changes in milliseconds. |
---
## Features
- **Support for all modes:** `normal`, `longpoll`, `continuous`, `eventsource`.
- **Filtering via `selector`:** Uses the `POST` method when `selector` is present.
- **Automatic reconnection:** Enabled in `live` mode.
- **Heartbeat support:** Keeps connections alive.
- **Scalable:** Asynchronous stream for handling large data volumes.
---
## Methods
### `stop`
```typescript
public stop(): void
```
Stops the current changes stream.
**Example:**
```typescript
changesStream.stop();
```
---
## Errors
- `HTTP error: 400`: Invalid request parameters (e.g., incorrect `selector`).
- `HTTP error: 401`: Authentication error.
- `Error parsing change`: Failed to parse data from the stream.
---
## Usage Tips
1. **Use `heartbeat`** to keep connections alive.
2. **Add exception handling** to properly manage network errors.
3. **Prefer `selector` over `filter`** for more flexible document selection.
4. **Call `stop`** when done to terminate the stream gracefully.
---
## License
This project is distributed under the [MIT License](LICENSE).
---
For questions or issues, feel free to reach out! 🚀
@@ -0,0 +1,5 @@
export default {
preset: "ts-jest/presets/default-esm",
testEnvironment: "node",
testMatch: ["**/__tests__/**/*.test.ts", "**/?(*.)+(spec|test).ts"],
};
@@ -0,0 +1,50 @@
{
"name": "@hereconnect/couchdb-changes-stream",
"version": "1.0.1",
"type": "module",
"repository": "https://github.com/Ti-webdev/couchdb-changes-stream",
"bugs": "https://github.com/Ti-webdev/couchdb-changes-stream/issues",
"description": "Stream CouchDB changes feed with support for pause, resume, and reconnection in TypeScript.",
"main": "./dist/CouchDBChangesStream.js",
"types": "./dist/CouchDBChangesStream.d.ts",
"exports": {
".": {
"import": "./dist/CouchDBChangesStream.js",
"default": "./dist/CouchDBChangesStream.js"
}
},
"browser": "./dist/CouchDBChangesStream.js",
"files": [
"dist/**/*"
],
"scripts": {
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"prepublishOnly": "npm run build",
"prepare": "npm run build"
},
"keywords": [
"couchdb",
"changes feed",
"typescript",
"stream",
"reconnection"
],
"author": "Your Name",
"license": "MIT",
"devDependencies": {
"@jest/types": "^29.6.3",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.9",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"fast-check": "^3.23.1",
"jest": "^29.7.0",
"prettier": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2",
"vitest": "^0.34.6"
}
}
@@ -0,0 +1,292 @@
export type CouchDBChange<T> = {
seq: string | number;
id: string;
changes: { rev: string }[];
deleted?: boolean;
doc?: T;
};
export interface CouchDBChangesOptions {
since?: string | number;
filter?: string;
doc_ids?: string[] | readonly string[];
selector?: Record<string, unknown>;
conflicts?: boolean;
descending?: boolean;
heartbeat?: boolean | number;
include_docs?: boolean;
attachments?: boolean;
att_encoding_info?: boolean;
limit?: number;
style?: "main_only" | "all_docs";
timeout?: number;
seq_interval?: number;
feed?: "normal" | "longpoll" | "continuous" | "eventsource"; // Добавлено поле feed
live?: boolean;
abortController?: AbortController;
fetch?: typeof globalThis.fetch;
}
export class CouchDBChangesStream<T> {
private readonly options:
| CouchDBChangesOptions
| Readonly<CouchDBChangesOptions>;
private readonly abortController: AbortController;
private readonly fetch: typeof globalThis.fetch;
private activeSeq?: string | number;
constructor(
private readonly dbUrl: string,
{
abortController,
fetch,
...options
}: CouchDBChangesOptions | Readonly<CouchDBChangesOptions> = {},
) {
this.options = options;
this.abortController = abortController || new AbortController();
this.fetch = fetch || globalThis.fetch;
}
private buildUrl(): URL {
const url = new URL(`${this.dbUrl}/_changes`);
Object.entries(this.options).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
if (key !== "doc_ids" && key !== "selector" && key !== "live") {
url.searchParams.append(key, value.toString());
}
}
});
// Add `since` parameter if `activeSeq` is set
if (this.activeSeq !== undefined) {
url.searchParams.set("since", this.activeSeq.toString());
}
return url;
}
private async *fetchChanges(): AsyncGenerator<
CouchDBChange<T>,
void,
unknown
> {
const headers = { Accept: "application/json" };
const signal = this.abortController.signal;
const isNormal = this.options.feed === "normal" || !this.options.feed;
const isLongpoll = this.options.feed === "longpoll";
const isContinuous = this.options.feed === "continuous";
const isEventsource = this.options.feed === "eventsource";
// Heartbeat configuration
const usesHeartbeat =
(isContinuous || isEventsource) && this.options.heartbeat !== 0;
let heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
let heartbeatPromise: Promise<never> = new Promise<never>(() => {});
signal.addEventListener("abort", () => {
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
});
const resetHeartbeat = () => {
if (!usesHeartbeat) return;
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
}
const heartbeatInterval =
typeof this.options.heartbeat === "number"
? this.options.heartbeat
: 60000; // Default 60 seconds
const timeoutBuffer = Math.max(heartbeatInterval * 0.1, 1000); // 10% buffer or at least 1s
const adjustedTimeout = heartbeatInterval + timeoutBuffer;
heartbeatPromise = new Promise<never>((resolve, reject) => {
heartbeatTimeout = setTimeout(() => {
reject(
new Error(
`Heartbeat timeout: no data received from server after ${adjustedTimeout} ms`,
),
);
}, adjustedTimeout);
if (typeof heartbeatTimeout === "object" && heartbeatTimeout.unref) {
heartbeatTimeout.unref();
}
});
};
let retryCount = 0;
while (!signal.aborted) {
try {
resetHeartbeat();
const url = this.buildUrl(); // Dynamic URL update with new `since`
const hasDocIds =
Array.isArray(this.options.doc_ids) &&
this.options.doc_ids.length > 0;
const hasSelector =
this.options.selector && typeof this.options.selector === "object";
const requestBody = hasDocIds
? { doc_ids: this.options.doc_ids }
: hasSelector
? { selector: this.options.selector }
: undefined;
const body = requestBody ? JSON.stringify(requestBody) : undefined;
const method = body ? "POST" : "GET";
const response = await Promise.race([
this.fetch(`${url.origin}${url.pathname}${url.search}`, {
method,
headers: {
...headers,
...(method === "POST"
? { "Content-Type": "application/json" }
: {}),
...(url.username && url.password
? {
Authorization: `Basic ${btoa(`${url.username}:${url.password}`)}`,
}
: {}),
},
body,
signal,
}),
heartbeatPromise,
]);
if (!response.ok) {
// console.error(`[fetchChanges] HTTP error: ${response.status}`);
const textError = await Promise.race([
response.text(),
heartbeatPromise,
]);
throw new Error(`HTTP error: ${response.status}\n${textError}`);
}
if (isNormal || isLongpoll) {
const data = await Promise.race([response.json(), heartbeatPromise]);
for (const change of data.results) {
this.activeSeq = change.seq;
yield change;
}
if (data.last_seq) {
this.activeSeq = data.last_seq;
}
// If `longpoll`, restart the loop after processing changes
if (isLongpoll && !signal.aborted) {
continue;
}
return; // Exit for `normal` after processing all changes
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Unable to read response body");
const decoder = new TextDecoder();
let buffer = "";
while (!signal.aborted) {
const { value, done } = await Promise.race([
reader.read(),
heartbeatPromise,
]);
resetHeartbeat();
buffer += decoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = buffer.indexOf("\n")) >= 0) {
if (signal.aborted) {
return;
}
let line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (isEventsource) {
if (line.startsWith("event:")) {
continue; // Игнорируем строки event:
}
if (line.startsWith("data:")) {
line = line.slice(5);
}
}
if (line.trim() === "") {
if (done) {
return;
}
continue;
}
try {
const change: CouchDBChange<T> = JSON.parse(line);
this.activeSeq = change.seq;
yield change;
} catch (error) {
if (!signal.aborted && buffer.indexOf("\n", newlineIndex)) {
throw new Error(
`[fetchChanges] Error parsing change: ${line} ${error}`,
);
}
}
}
if (done || signal.aborted) {
return; // No more data to read
}
}
} catch (error) {
if (signal.aborted) break;
if (this.options.live) {
retryCount++;
const backoff = Math.min(2 ** retryCount * 100, 30000); // Exponential backoff with max 30 seconds
const jitter = Math.random() * 100; // Add jitter
const delay = backoff + jitter;
console.debug(
`[fetchChanges] Error fetching changes: ${
error instanceof Error ? error.message : error
}. Retrying in ${(delay / 1000).toFixed(2)} seconds...`,
);
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout);
heartbeatTimeout = null;
}
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
}
public async *[Symbol.asyncIterator](): AsyncIterableIterator<
CouchDBChange<T>
> {
try {
yield* this.fetchChanges();
} finally {
this.abortController.abort(); // Ensure we abort if the iterator is closed
}
}
public stop() {
this.abortController.abort();
}
}
@@ -0,0 +1,133 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - Automatic Reconnection", () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("should automatically reconnect and continue from the last seq without duplicates", async () => {
jest.useFakeTimers();
// Simulate changes before and after connection drop
const initialChanges: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }] },
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }] },
// Connection will drop after this change
];
const subsequentChanges: CouchDBChange<any>[] = [
{ seq: 3, id: "doc3", changes: [{ rev: "1-ghi" }] },
{ seq: 4, id: "doc4", changes: [{ rev: "1-jkl" }] },
];
const mockFetch = jest.fn();
let fetchCallCount = 0;
mockFetch.mockImplementation(async () => {
fetchCallCount++;
if (fetchCallCount === 1) {
// First fetch returns initial changes and simulates a connection drop
const responseStream = initialChanges
.map((change) => JSON.stringify(change) + "\n")
.join("");
const body = new ReadableStream({
start(controller) {
// Enqueue initial changes
controller.enqueue(new TextEncoder().encode(responseStream));
// Simulate connection drop after a short delay
setTimeout(() => {
controller.error(new Error("Connection dropped"));
}, 100); // 100ms задержка перед разрывом соединения
},
});
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body,
};
return mockResponse as Response;
} else {
// Subsequent fetch returns remaining changes
const responseStream = subsequentChanges
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseStream));
controller.close();
},
}),
};
return mockResponse as Response;
}
});
// Suppress console errors for clean test output
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: true,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const iterator = stream[Symbol.asyncIterator]();
// Process changes asynchronously
const processChanges = async () => {
try {
for await (const change of iterator) {
result.push(change);
// Simulate processing time
await new Promise((resolve) => setTimeout(resolve, 10));
if (result.length === 4) {
stream.stop();
}
}
} catch (error) {
// Should not reach here
fail("Unexpected error during processing: " + error);
}
};
const processingPromise = processChanges();
// Advance timers to simulate passage of time
await jest.advanceTimersByTimeAsync(650);
// At this point, connection should have dropped and reconnection should occur
expect(fetchCallCount).toBeGreaterThanOrEqual(2);
// Finish processing
await processingPromise;
// Verify that no duplicates are present
const seqs = result.map((change) => change.seq);
const uniqueSeqs = Array.from(new Set(seqs));
expect(seqs).toEqual(uniqueSeqs);
// Verify that all changes are received
const expectedChanges = [...initialChanges, ...subsequentChanges];
expect(result).toEqual(expectedChanges);
consoleErrorSpy.mockRestore();
jest.useRealTimers();
});
});
@@ -0,0 +1,230 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream Error Handling", () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("retries fetch on connection drop and doesn't hang", async () => {
jest.useFakeTimers();
// Mock fetch to simulate connection drop
const mockFetch = jest.fn();
// First call to fetch will reject with a connection error
const connectionError = new Error("Connection dropped");
// Keep track of how many times fetch is called
let fetchCallCount = 0;
mockFetch.mockImplementation(async () => {
fetchCallCount++;
if (fetchCallCount === 1) {
throw connectionError;
} else {
// After the first failure, return a valid response
const changes: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
];
const responseStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseStream));
controller.close();
},
}),
};
return mockResponse as Response;
}
});
// Mock Math.random to return 0, so jitter is 0
jest.spyOn(Math, "random").mockReturnValue(0);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: true,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const iterator = stream[Symbol.asyncIterator]();
// Start fetching the first change
const nextPromise = iterator.next();
// Advance timers to allow the first fetch attempt
await Promise.resolve(); // Ensure any pending promises are resolved
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(fetchCallCount).toBe(1);
// Since the first fetch fails, it should schedule a retry with backoff
// The backoff delay for retryCount = 1 is 2^1 * 100 = 200ms
// Jitter is 0
await Promise.resolve(); // Allow promises to resolve
// Advance timers by 200ms to trigger the retry
jest.advanceTimersByTime(200);
// Wait for the retry to occur
await Promise.resolve(); // Allow promises to resolve
//
// expect(mockFetch).toHaveBeenCalledTimes(2);
// expect(fetchCallCount).toBe(2);
// Now the fetch should succeed, and we can get the next change
const next = await nextPromise;
expect(next.done).toBe(false);
result.push(next.value);
// Stop the stream
stream.stop();
const final = await iterator.next();
expect(final.done).toBe(true);
expect(result).toHaveLength(1);
expect(result[0].seq).toBe(1);
jest.useRealTimers();
jest.spyOn(Math, "random").mockRestore();
});
it("throws an exception when there is a malformed JSON in response", async () => {
// Mock fetch to return a response with malformed JSON
const changesResponse = `
{"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
MALFORMED_JSON
{"seq":2,"id":"doc2","changes":[{"rev":"1-def"}]}
`;
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(changesResponse));
controller.close();
},
}),
};
const mockFetch = jest.fn<
Promise<Response>,
[RequestInfo | URL, RequestInit?]
>(async () => {
return mockResponse as Response;
}) as jest.MockedFunction<typeof fetch>;
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: false,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
let caughtError: Error | null = null;
try {
for await (const change of stream) {
result.push(change);
}
} catch (error) {
caughtError = error as Error;
}
// Check that an error was thrown
expect(caughtError).not.toBeNull();
expect(caughtError?.message).toContain(
"[fetchChanges] Error parsing change:",
);
// Ensure only valid changes were processed before the error
expect(result).toHaveLength(1);
expect(result[0].seq).toBe(1);
});
it("stops after processing all changes when live is false", async () => {
// Mock fetch to return a finite response
const changesResponse = `
{"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
{"seq":2,"id":"doc2","changes":[{"rev":"1-def"}]}
{"seq":3,"id":"doc3","changes":[{"rev":"1-ghi"}]}
`;
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(changesResponse));
controller.close();
},
}),
};
const mockFetch = jest.fn<
Promise<Response>,
[RequestInfo | URL, RequestInit?]
>(async (input, init) => {
return mockResponse as Response;
}) as jest.MockedFunction<typeof fetch>;
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: false,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// The stream should complete after processing all changes
expect(result).toHaveLength(3);
expect(result[0].seq).toBe(1);
expect(result[1].seq).toBe(2);
expect(result[2].seq).toBe(3);
// Verify fetch was called exactly once since live mode is disabled
expect(mockFetch).toHaveBeenCalledTimes(1);
// Access the first call arguments
const calledArgs = mockFetch.mock.calls[0];
// Ensure that calledArgs is defined and has at least one element
expect(calledArgs).toBeDefined();
expect(calledArgs.length).toBeGreaterThanOrEqual(1);
const [calledUrl, calledOptions] = calledArgs;
// Validate that the URL is correct
expect(calledUrl).toBe("http://mock-couchdb/_changes?feed=continuous");
// Validate that options exist and contain the expected method
if (calledOptions) {
expect(calledOptions.method).toBe("GET");
} else {
fail("Expected calledOptions to be defined");
}
});
});
@@ -0,0 +1,56 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - EventSource Format", () => {
it("should parse changes from text/event-stream format correctly", async () => {
const eventSourceData = `
event: change
data: {"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
event: change
data: {"seq":2,"id":"doc2","changes":[{"rev":"2-def"}]}
event: change
data: {"seq":3,"id":"doc3","changes":[{"rev":"3-ghi"}]}
`;
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
headers: new Headers({ "Content-Type": "text/event-stream" }),
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(eventSourceData));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => mockResponse as Response);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "eventsource",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// Ожидаемый результат
const expectedChanges: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }] },
{ seq: 2, id: "doc2", changes: [{ rev: "2-def" }] },
{ seq: 3, id: "doc3", changes: [{ rev: "3-ghi" }] },
];
// Проверяем, что результат соответствует ожидаемым изменениям
expect(result).toEqual(expectedChanges);
// Проверяем, что fetch был вызван
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,133 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - Heartbeat", () => {
let mockFetch: jest.Mock;
const dbUrl = "http://localhost:5984/mydb";
beforeEach(() => {
mockFetch = jest.fn();
jest.useFakeTimers(); // Подключаем fake timers
});
afterEach(() => {
jest.useRealTimers(); // Отключаем fake timers после каждого теста
jest.restoreAllMocks();
});
it("should reset heartbeat timer on receiving data", async () => {
const mockResponseData = {
seq: "1",
id: "doc1",
changes: [{ rev: "1-abc" }],
};
mockFetch.mockResolvedValueOnce({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify(mockResponseData) + "\n"),
);
controller.close();
},
}),
});
const stream = new CouchDBChangesStream(dbUrl, {
feed: "continuous",
heartbeat: 30_000, // 30 секунд
live: true,
fetch: mockFetch,
});
const iterator = stream[Symbol.asyncIterator]();
const heartbeatSpy = jest.spyOn(global, "setTimeout");
const firstChange = await iterator.next();
expect(firstChange.done).toBe(false);
expect(firstChange.value.seq).toBe("1");
// Таймер heartbeat должен был быть установлен
expect(heartbeatSpy).toHaveBeenCalledWith(expect.any(Function), 33_000); // 5000 + 10% буфер,
});
it("should throw an error if no data is received within heartbeat + buffer", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
body: new ReadableStream({
start(controller) {
// do not send any data to test heartbeat timeout
},
}),
});
const stream = new CouchDBChangesStream(dbUrl, {
feed: "continuous",
heartbeat: 20_000,
fetch: mockFetch,
});
const iterator = stream[Symbol.asyncIterator]();
const fetchPromise = iterator.next();
// Прокручиваем время до истечения heartbeat таймера (20_000 + 10%)
jest.advanceTimersByTime(22_000);
await expect(fetchPromise).rejects.toThrow(
"Heartbeat timeout: no data received from server after 22000 ms",
);
});
it("should continue working if data is received before heartbeat timeout", async () => {
const mockResponseData = {
seq: "1",
id: "doc1",
changes: [{ rev: "1-abc" }],
};
mockFetch.mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
// Отправляем данные перед истечением таймера
setTimeout(() => {
controller.enqueue(
new TextEncoder().encode(JSON.stringify(mockResponseData) + "\n"),
);
controller.close();
}, 2000); // Data arrives before heartbeat timeout (3000)
},
}),
});
const stream = new CouchDBChangesStream(dbUrl, {
feed: "continuous",
heartbeat: 3_000,
fetch: mockFetch,
live: true,
});
const iterator = stream[Symbol.asyncIterator]();
const nextPromise = iterator.next();
await Promise.resolve();
jest.advanceTimersByTime(2_999);
await Promise.resolve();
const firstChange = await nextPromise;
expect(firstChange.done).toBe(false);
expect(firstChange.value.seq).toBe("1");
// Let's make sure that a timeout error does not occur
await Promise.resolve();
jest.advanceTimersByTime(2_999);
await Promise.resolve();
const secondFetch = iterator.next();
await expect(secondFetch).resolves.not.toThrow();
});
});
@@ -0,0 +1,125 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - Limit Parameter", () => {
it("should return only the specified number of changes when limit is set", async () => {
const changes: CouchDBChange<any>[] = Array.from(
{ length: 10 },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async (url) => {
const urlObj = new URL(url);
const limitParam = urlObj.searchParams.get("limit");
const limit = limitParam ? parseInt(limitParam, 10) : changes.length;
const responseStream = changes
.slice(0, limit) // Применяем limit
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseStream));
controller.close();
},
}),
};
return mockResponse as Response;
});
for (const limit of [1, 5, 10]) {
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
limit,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// Проверяем, что количество возвращенных изменений соответствует лимиту
expect(result).toHaveLength(limit);
// Проверяем, что полученные изменения соответствуют первым `limit` изменениям
expect(result.map((r) => r.seq)).toEqual(
changes.slice(0, limit).map((c) => c.seq),
);
}
expect(mockFetch).toHaveBeenCalledTimes(3); // Проверяем, что fetch вызывался для каждого limit
});
it("should return all changes if limit is not set", async () => {
const changes: CouchDBChange<any>[] = Array.from(
{ length: 10 },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => mockResponse as Response);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// Проверяем, что возвращены все изменения
expect(result).toHaveLength(changes.length);
// Проверяем, что полученные изменения соответствуют всем изменениям
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,77 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
if (typeof global.gc !== "function") {
console.warn(
"Skipping tests: Run Node.js with --expose-gc to enable garbage collection.",
);
test.skip("Garbage collection is not enabled.", () => {});
} else {
describe("CouchDBChangesStream Memory Leak Test", () => {
it("should not cause memory leaks after being stopped", async () => {
// Mock fetch response
const changes: CouchDBChange<any>[] = Array.from(
{ length: 1000 },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
changes.forEach((change) => {
const changeString = JSON.stringify(change) + "\n";
controller.enqueue(new TextEncoder().encode(changeString));
});
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => mockResponse as Response);
// Measure memory before creating the stream
if (global.gc) global.gc(); // Force garbage collection
const memoryBefore = process.memoryUsage().heapUsed;
// Create and consume the stream
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
if (result.length === changes.length) {
stream.stop();
}
}
// Stop the stream and allow garbage collection
stream.stop();
if (global.gc) global.gc();
// Measure memory after the stream is stopped
const memoryAfter = process.memoryUsage().heapUsed;
// Log memory usage for analysis
console.log(`Memory Before: ${memoryBefore}`);
console.log(`Memory After: ${memoryAfter}`);
console.log(`Memory Difference: ${memoryAfter - memoryBefore}`);
// Allow for slightly larger tolerances in memory usage differences
const memoryLeakThreshold = 1024 * 100; // 100 KB
expect(memoryAfter - memoryBefore).toBeLessThan(memoryLeakThreshold);
});
});
}
@@ -0,0 +1,168 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream Performance and Scalability", () => {
it("processes a large number of changes correctly and completely", async () => {
const changeCount = 1000;
const changes: CouchDBChange<any>[] = Array.from(
{ length: changeCount },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => {
return mockResponse as Response;
});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
if (result.length === changeCount) {
stream.stop();
}
}
expect(result).toHaveLength(changeCount);
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
});
it("maintains stability with increasing data volume", async () => {
const largeVolumes = [100, 1000, 5000];
const performanceResults: Record<number, number> = {};
for (const volume of largeVolumes) {
const changes: CouchDBChange<any>[] = Array.from(
{ length: volume },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => {
return mockResponse as Response;
});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const startTime = performance.now();
for await (const change of stream) {
result.push(change);
if (result.length === volume) {
stream.stop();
}
}
const endTime = performance.now();
const elapsedTime = endTime - startTime;
performanceResults[volume] = elapsedTime;
expect(result).toHaveLength(volume);
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
}
console.log("Performance Results (ms):", performanceResults);
});
it("handles high throughput with multiple chunks of data", async () => {
const chunkSize = 500;
const chunkCount = 5;
const changes: CouchDBChange<any>[] = Array.from(
{ length: chunkSize * chunkCount },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
changes.forEach((change, i) => {
const changeString = JSON.stringify(change) + "\n";
controller.enqueue(new TextEncoder().encode(changeString));
if ((i + 1) % chunkSize === 0) {
// Simulate chunked data
controller.enqueue(new TextEncoder().encode("\n"));
}
});
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => {
return mockResponse as Response;
});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
if (result.length === changes.length) {
stream.stop();
}
}
expect(result).toHaveLength(changes.length);
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
});
});
@@ -0,0 +1,491 @@
import {
CouchDBChangesStream,
CouchDBChange,
CouchDBChangesOptions,
} from "../src/CouchDBChangesStream";
import * as fc from "fast-check";
type NestedDocument = {
_id: string;
_rev: string;
type: string;
data: {
nested: {
field: string;
value: number;
};
};
};
type SimpleDocument = {
_id: string;
_rev: string;
type: string;
};
function createMockFetch<T>(
changes: CouchDBChange<T>[],
): jest.Mock<Promise<Response>> {
return jest.fn(async () => {
const responseStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseStream));
controller.close();
},
}),
};
return mockResponse as Response;
});
}
describe("CouchDBChangesStream", () => {
it("returns changes in the correct order and type", async () => {
const changes: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
];
const mockFetch = createMockFetch(changes);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(changes.length);
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
});
it("ensures pause between processing changes", async () => {
const changes: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
];
const mockFetch = createMockFetch(changes);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
let processing = false;
for await (const change of stream) {
expect(processing).toBe(false);
processing = true;
// Simulate long processing time
await new Promise((resolve) => setTimeout(resolve, 100));
result.push(change);
processing = false;
if (result.length === changes.length) {
stream.stop();
}
}
expect(result).toHaveLength(changes.length);
});
it("correctly stops when stop() or AbortController is called", async () => {
const changes: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
];
const mockFetch = createMockFetch(changes);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const iterator = stream[Symbol.asyncIterator]();
// Read first change
const next1 = await iterator.next();
expect(next1.done).toBe(false);
result.push(next1.value);
// Call stop()
stream.stop();
// Attempt to read the next change
const next2 = await iterator.next();
expect(next2.done).toBe(true);
expect(result).toHaveLength(1);
});
it("stops the stream when AbortController is aborted", async () => {
const changes: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
];
const abortController = new AbortController();
const mockFetch = createMockFetch(changes);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
abortController,
});
const result: CouchDBChange<any>[] = [];
const iterator = stream[Symbol.asyncIterator]();
// Read first change
const next1 = await iterator.next();
expect(next1.done).toBe(false);
result.push(next1.value);
// Abort the signal
abortController.abort();
// Attempt to read the next change
const next2 = await iterator.next();
expect(next2.done).toBe(true);
expect(result).toHaveLength(1);
});
it("isolated test for filter parameter", async () => {
const mockFetch = createMockFetch([]);
const options = {
filter: "",
doc_ids: [],
feed: "continuous",
fetch: mockFetch,
} as const;
const stream = new CouchDBChangesStream<any>(
"http://mock-couchdb",
options,
);
const iterator = stream[Symbol.asyncIterator]();
await iterator.next();
expect(mockFetch).toHaveBeenCalledTimes(1);
const fetchCallArgs = mockFetch.mock.calls[0];
const fetchUrl = fetchCallArgs[0] as string;
const url = new URL(fetchUrl);
const params = url.searchParams;
expect(params.has("filter")).toBe(false);
});
it("correctly reflects passed parameters in the HTTP request", async () => {
const mockFetch = createMockFetch([]);
const options = {
since: "now",
filter: "test_filter",
doc_ids: ["doc1", "doc2"],
feed: "continuous",
conflicts: true,
descending: false,
include_docs: true,
limit: 5,
fetch: mockFetch,
} as const;
const stream = new CouchDBChangesStream<any>(
"http://mock-couchdb",
options,
);
const iterator = stream[Symbol.asyncIterator]();
await iterator.next();
expect(mockFetch).toHaveBeenCalledTimes(1);
const fetchCallArgs = mockFetch.mock.calls[0];
const fetchUrl = fetchCallArgs[0] as string;
const fetchOptions = fetchCallArgs[1] as RequestInit;
if (!fetchOptions) {
throw new Error("fetchOptions is undefined");
}
const url = new URL(fetchUrl);
const params = url.searchParams;
expect(params.get("since")).toBe("now");
expect(params.get("filter")).toBe("test_filter");
expect(params.get("include_docs")).toBe("true");
expect(params.get("limit")).toBe("5");
expect(fetchOptions.method).toBe("POST");
expect(fetchOptions.body).toEqual(
JSON.stringify({ doc_ids: ["doc1", "doc2"] }),
);
});
it("correctly maps changes to the specified document type", async () => {
const nestedChanges: CouchDBChange<NestedDocument>[] = [
{
seq: 1,
id: "nested_doc1",
changes: [{ rev: "1-nested" }],
doc: {
_id: "nested_doc1",
_rev: "1-nested",
type: "nested",
data: { nested: { field: "example", value: 42 } },
},
},
];
const simpleChanges: CouchDBChange<SimpleDocument>[] = [
{
seq: 2,
id: "simple_doc1",
changes: [{ rev: "1-simple" }],
doc: {
_id: "simple_doc1",
_rev: "1-simple",
type: "simple",
},
},
];
const mockFetchNested = createMockFetch(nestedChanges);
const mockFetchSimple = createMockFetch(simpleChanges);
const nestedStream = new CouchDBChangesStream<NestedDocument>(
"http://mock-couchdb",
{ include_docs: true, feed: "continuous", fetch: mockFetchNested },
);
const simpleStream = new CouchDBChangesStream<SimpleDocument>(
"http://mock-couchdb",
{ include_docs: true, feed: "continuous", fetch: mockFetchSimple },
);
const nestedResult: CouchDBChange<NestedDocument>[] = [];
for await (const change of nestedStream) {
nestedResult.push(change);
}
const simpleResult: CouchDBChange<SimpleDocument>[] = [];
for await (const change of simpleStream) {
simpleResult.push(change);
}
// Validate nested document results
expect(nestedResult).toHaveLength(nestedChanges.length);
expect(nestedResult[0].doc).toEqual(nestedChanges[0].doc);
// Validate simple document results
expect(simpleResult).toHaveLength(simpleChanges.length);
expect(simpleResult[0].doc).toEqual(simpleChanges[0].doc);
});
it("excludes 'live' from URL parameters but affects behavior", async () => {
const mockFetch = createMockFetch([]);
const options: CouchDBChangesOptions = {
since: "now",
feed: "continuous",
filter: "test_filter",
live: true, // This is the key parameter we're testing
include_docs: true,
fetch: mockFetch,
};
const stream = new CouchDBChangesStream<any>(
"http://mock-couchdb",
options,
);
const iterator = stream[Symbol.asyncIterator]();
await iterator.next();
expect(mockFetch).toHaveBeenCalledTimes(1);
const fetchCallArgs = mockFetch.mock.calls[0];
const fetchUrl = fetchCallArgs[0] as string;
const fetchOptions = fetchCallArgs[1] as RequestInit;
if (!fetchOptions) {
throw new Error("fetchOptions is undefined");
}
const url = new URL(fetchUrl);
const params = url.searchParams;
// Ensure 'live' does not appear as a query parameter
expect(params.get("live")).toBeNull();
// Check that other parameters are still included
expect(params.get("since")).toBe("now");
expect(params.get("filter")).toBe("test_filter");
expect(params.get("include_docs")).toBe("true");
// Validate the fetch method and body
expect(fetchOptions.method).toBe("GET");
expect(fetchOptions.body).toBeUndefined();
// Further behavior checks can be added for live mode if necessary
});
it("adds Basic Auth header when credentials are included in URL", async () => {
const mockFetch = jest.fn(
async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
return new Response(JSON.stringify({ results: [], last_seq: "0" }));
},
) as jest.MockedFunction<typeof fetch>;
const options = {
since: "now",
include_docs: true,
fetch: mockFetch,
};
const stream = new CouchDBChangesStream<any>(
"https://admin:password@mock-couchdb",
options,
);
const iterator = stream[Symbol.asyncIterator]();
await iterator.next();
expect(mockFetch).toHaveBeenCalledTimes(1);
const fetchCallArgs = mockFetch.mock.calls[0];
const fetchOptions = fetchCallArgs[1] as RequestInit;
expect(fetchOptions?.headers).toBeDefined();
expect(
"Authorization" in fetchOptions?.headers! &&
fetchOptions?.headers!["Authorization"],
).toBe("Basic " + btoa("admin:password"));
});
it("processes all changes and stops in 'normal' mode", async () => {
// Mock fetch to return a finite response
const changesResponse = {
results: [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
],
last_seq: "2",
};
const mockFetch = jest.fn(
async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
return new Response(JSON.stringify(changesResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "normal",
include_docs: true,
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// Check results
expect(result).toHaveLength(2);
expect(result[0].seq).toBe(1);
expect(result[1].seq).toBe(2);
// Check fetch call
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("_changes");
});
it("processes changes in 'longpoll' mode and continues waiting for updates", async () => {
// Mock fetch to return different responses on each call
const firstResponse = {
results: [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
],
last_seq: "1",
};
const secondResponse = {
results: [
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false },
],
last_seq: "2",
};
const mockFetch = jest
.fn()
.mockImplementationOnce(async () => {
return new Response(JSON.stringify(firstResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
})
.mockImplementationOnce(async () => {
return new Response(JSON.stringify(secondResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as jest.MockedFunction<typeof fetch>;
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "longpoll",
include_docs: true,
live: true,
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const iterator = stream[Symbol.asyncIterator]();
// Read first set of changes
result.push((await iterator.next()).value);
// Read second set of changes
result.push((await iterator.next()).value);
// Stop the stream
stream.stop();
// Check results
expect(result).toHaveLength(2);
expect(result[0].seq).toBe(1);
expect(result[1].seq).toBe(2);
// Check fetch calls
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["DOM", "ES2020"],
"outDir": "dist",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"declaration": true,
"types": ["node","jest"] // Добавляем поддержку Jest
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
@@ -1,11 +1,12 @@
import type { BaseDocument } from "./BaseDocument";
import type { PushSubscription } from "web-push";
export interface WebPushSubscriptionDocument extends BaseDocument {
_id: string; // Формат: "web_push_subscription:<user_uuid>:<browser_uuid>"
type: "web_push_subscription";
user_uuid: string; // UUID пользователя, связанного с подпиской
browser_uuid: string; // UUID браузера, связанного с подпиской
subscription: PushSubscriptionJSON;
subscription: PushSubscription;
created_at: string; // Дата создания подписки в ISO формате
updated_at?: string; // Дата последнего обновления подписки
}
+6
View File
@@ -6,5 +6,11 @@
"private": true,
"scripts": {
"build": "tsc"
},
"dependencies": {
"typescript": "~5.6.3"
},
"devDependencies": {
"@types/web-push": "^3.6.4"
}
}
+6946
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
packages:
- "apps/*"
- "packages/*"
- "services/*"
@@ -0,0 +1,3 @@
node_modules
dist
*.log
@@ -0,0 +1 @@
dist
@@ -1,5 +1,5 @@
{
"name": "@hereconnect/message-delivery-method-web-push",
"name": "@hereconnect/service-message-delivery-method-web-push",
"version": "1.0.0",
"description": "",
"main": "src/server.ts",
@@ -24,16 +24,25 @@
},
"contributors": [],
"scripts": {
"build": "tsc",
"generate-keys": "node ./src/generate-keys.js",
"test-send-notification": "node ./src/test-send-notification.js",
"test-send-notification-cancel": "node ./src/test-send-notification-cancel.js"
},
"dependencies": {
"@hereconnect/couchdb-changes-stream": "workspace:^",
"@hereconnect/types": "^1.0.0",
"couchdb-changes-stream": "^1.0.1",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-core": "^9.0.0",
"pouchdb-find": "^9.0.0",
"typescript": "~5.6.3",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/node": "^20.17.6",
"@types/pouchdb-adapter-http": "^6.1.6",
"@types/pouchdb-core": "^7.0.15",
"@types/pouchdb-find": "^7.3.3",
"@types/web-push": "^3.6.4"
}
}
@@ -0,0 +1,57 @@
import http from "http";
/**
* Запускает HTTP-сервер для проверки здоровья.
* @param controller - AbortController для управления состоянием здоровья.
* @param defaultPort - Порт для сервера по умолчанию (если не указан в ENV HEALTH_PORT).
* @param defaultHostName - Имя хоста для сервера по умолчанию (если не указан в ENV HEALTH_HOST).
*/
export function startHealthServer(
controller: AbortController,
defaultPort: number = 8000,
defaultHostName: string = "127.0.0.1",
): void {
const port = parseInt(process.env.HEALTH_PORT || `${defaultPort}`, 10);
const hostname = process.env.HEALTH_HOST || defaultHostName;
const server = http.createServer((req, res) => {
if (req.url === "/health") {
const signal = controller.signal;
// Если сигнал отмены активирован, сервер помечается как "unhealthy"
if (signal.aborted) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "unhealthy",
reason: "Service aborted",
}),
);
return;
}
// Возвращаем стандартный статус "ok", если всё нормально
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status: "ok",
}),
);
} else {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
}
});
server.listen(port, hostname, () => {
console.log(
`Healthcheck server running on http://${hostname}:${port}/health`,
);
});
// Закрываем сервер при активации AbortController
controller.signal.addEventListener("abort", () => {
console.log("Healthcheck server shutting down due to abort signal.");
server.close();
});
}
@@ -1,21 +1,27 @@
import webpush from "web-push";
import { CouchDBChangesStream } from "couchdb-changes-stream";
import { CouchDBChangesStream } from "@hereconnect/couchdb-changes-stream";
import { qrDb, messagesDbUrl, webPushSubscriptionsDb } from "./dbs";
import { setupVapidDetails } from "./setupVapidDetails";
import type { MessageDocument } from "@hereconnect/types";
import { ServiceSeqTracker } from "./serviceSeqTracker";
import { startHealthServer } from "./health-server";
const serviceSeqTracker = new ServiceSeqTracker();
const abortController = new AbortController();
startHealthServer(abortController);
const start = async () => {
await setupVapidDetails();
const since = await serviceSeqTracker.getSince();
const serviceSeqTracker = await ServiceSeqTracker.Init(
"_local/service:message-delivery-method-web-push",
);
const changesStream = new CouchDBChangesStream<MessageDocument>(
messagesDbUrl,
{
since,
abortController,
since: serviceSeqTracker.lastSeq,
feed: "continuous",
include_docs: true,
heartbeat: 1000,
@@ -28,12 +34,12 @@ const start = async () => {
// Gracefully handle SIGINT
process.on("SIGINT", async () => {
changesStream.stop();
changesStream?.stop();
process.stdout.write("\n");
process.exit(2);
});
console.info("Service started from sequence", since);
console.info("Service started from sequence", serviceSeqTracker.lastSeq);
for await (const change of changesStream) {
const { seq, doc: msgDoc } = change;
@@ -68,7 +74,7 @@ const start = async () => {
for (const row of rows) {
try {
const subscription = row.doc.subscription;
const subscription = row.doc!.subscription;
const topic = `chat-${msgDoc.qr_code_uri}-${msgDoc.chat}`;
const tag = `${topic}-${(new Date(msgDoc.created_at).getTime() - new Date(qrDoc.created_at).getTime()).toString(36)}`;
@@ -1,28 +1,32 @@
import { serviceDb, type ServiceDoc } from "./dbs";
const SERVICE_DOC_ID = "_local/service:message-delivery-method-web-push";
export class ServiceSeqTracker {
private serviceDoc: ServiceDoc;
async getSince() {
this.serviceDoc = await serviceDb.get(SERVICE_DOC_ID).catch((error) => {
/**
* @param _id
* @constructor
*/
static async Init(_id: string) {
const serviceDoc = await serviceDb.get(_id).catch((error) => {
if (error.name === "not_found") {
return {
_id: SERVICE_DOC_ID,
_id,
lastSeq: "0",
};
}
throw error; // Re-throw other errors
});
return new ServiceSeqTracker(serviceDoc);
}
// this.serviceDoc.lastSeq =
// "126-g1AAAACLeJzLYWBgYMpgTmHgzcvPy09JdcjLz8gvLskBCScyJNX___8_K4M50SUXKMCebGlpnmxpiq4Yh_Y8FiDJ0ACk_kNNsQKbYpSakphkYYKuJwsAb2YrPw";
private constructor(private serviceDoc: ServiceDoc) {}
get lastSeq() {
return this.serviceDoc.lastSeq;
}
checkNextSeq(seq: number | string) {
if (!this.serviceDoc) {
}
const next = parseInt(seq as string, 10);
const current = parseInt(this.serviceDoc.lastSeq as string, 10);
if (next < current) {
@@ -3,10 +3,10 @@ import { serverSettingsDb } from "./dbs";
import { ServerSettingsDocument } from "@hereconnect/types";
function isType<N extends ServerSettingsDocument["name"]>(
doc: ServerSettingsDocument,
doc: ServerSettingsDocument | null | undefined,
name: N,
): doc is ServerSettingsDocument & { name: N } {
return doc.name === name;
return !!(doc && doc.name === name);
}
export const setupVapidDetails = async () => {
@@ -53,9 +53,10 @@ export const setupVapidDetails = async () => {
);
changes.on("change", (change) => {
if (!("doc" in change)) {
if (!change.doc) {
return;
}
if (isType(change.doc, "message_delivery_method_web_push_vapid")) {
console.log("update vapid details");
webpush.setVapidDetails(
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}