react-native-qpush 0.0.0-stage → 1.0.0
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.
- package/README.md +439 -2
- package/android/build.gradle +77 -0
- package/android/gradle.properties +5 -0
- package/android/proguard-rules.pro +5 -0
- package/android/src/main/AndroidManifest.xml +29 -0
- package/android/src/main/java/com/qpush/reactnative/QPushDeviceInfo.kt +96 -0
- package/android/src/main/java/com/qpush/reactnative/QPushEventBus.kt +153 -0
- package/android/src/main/java/com/qpush/reactnative/QPushFcm.kt +108 -0
- package/android/src/main/java/com/qpush/reactnative/QPushHttp.kt +92 -0
- package/android/src/main/java/com/qpush/reactnative/QPushImages.kt +103 -0
- package/android/src/main/java/com/qpush/reactnative/QPushLog.kt +39 -0
- package/android/src/main/java/com/qpush/reactnative/QPushMessagingService.kt +77 -0
- package/android/src/main/java/com/qpush/reactnative/QPushNotificationOpenActivity.kt +121 -0
- package/android/src/main/java/com/qpush/reactnative/QPushNotificationRenderer.kt +256 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPayload.kt +114 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPushModule.kt +368 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPushPackage.kt +15 -0
- package/android/src/main/java/com/qpush/reactnative/QPushStore.kt +108 -0
- package/android/src/main/res/values/qpush_strings.xml +6 -0
- package/app.plugin.js +1 -0
- package/ios/Extension/QPushNotificationServiceExtension.swift +261 -0
- package/ios/Extension/QPushPushExtension.podspec +34 -0
- package/ios/QPushPush.m +62 -0
- package/ios/QPushPush.swift +186 -0
- package/ios/QPushPushBridge.h +5 -0
- package/ios/QPushPushCore.swift +457 -0
- package/ios/QPushPushHooks.m +237 -0
- package/ios/QPushPushSupport.swift +249 -0
- package/ios_nse_template/Info.plist +38 -0
- package/ios_nse_template/NotificationService.entitlements +10 -0
- package/ios_nse_template/NotificationService.swift +44 -0
- package/lib/NativeQPushPush.d.ts +44 -0
- package/lib/NativeQPushPush.js +4 -0
- package/lib/QPush.d.ts +117 -0
- package/lib/QPush.js +206 -0
- package/lib/core/QPushCore.d.ts +148 -0
- package/lib/core/QPushCore.js +1387 -0
- package/lib/core/constants.d.ts +37 -0
- package/lib/core/constants.js +40 -0
- package/lib/core/events.d.ts +11 -0
- package/lib/core/events.js +40 -0
- package/lib/core/http.d.ts +41 -0
- package/lib/core/http.js +123 -0
- package/lib/core/logger.d.ts +18 -0
- package/lib/core/logger.js +43 -0
- package/lib/core/operationQueue.d.ts +117 -0
- package/lib/core/operationQueue.js +264 -0
- package/lib/core/operations.d.ts +80 -0
- package/lib/core/operations.js +88 -0
- package/lib/core/payload.d.ts +20 -0
- package/lib/core/payload.js +187 -0
- package/lib/core/platform.d.ts +72 -0
- package/lib/core/platform.js +7 -0
- package/lib/core/state.d.ts +51 -0
- package/lib/core/state.js +74 -0
- package/lib/core/storage.d.ts +34 -0
- package/lib/core/storage.js +68 -0
- package/lib/core/subscription.d.ts +67 -0
- package/lib/core/subscription.js +98 -0
- package/lib/core/tags.d.ts +39 -0
- package/lib/core/tags.js +123 -0
- package/lib/core/url.d.ts +17 -0
- package/lib/core/url.js +64 -0
- package/lib/core/uuid.d.ts +2 -0
- package/lib/core/uuid.js +18 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +15 -0
- package/lib/notification.d.ts +67 -0
- package/lib/notification.js +73 -0
- package/lib/reactNativePlatform.d.ts +3 -0
- package/lib/reactNativePlatform.js +164 -0
- package/lib/types.d.ts +157 -0
- package/lib/types.js +31 -0
- package/package.json +70 -4
- package/plugin/withQPush.js +330 -0
- package/react-native-qpush.podspec +30 -0
- package/react-native.config.js +18 -0
- package/src/NativeQPushPush.ts +57 -0
- package/src/QPush.ts +292 -0
- package/src/core/QPushCore.ts +1515 -0
- package/src/core/constants.ts +42 -0
- package/src/core/events.ts +34 -0
- package/src/core/http.ts +146 -0
- package/src/core/logger.ts +42 -0
- package/src/core/operationQueue.ts +324 -0
- package/src/core/operations.ts +166 -0
- package/src/core/payload.ts +175 -0
- package/src/core/platform.ts +84 -0
- package/src/core/state.ts +127 -0
- package/src/core/storage.ts +76 -0
- package/src/core/subscription.ts +133 -0
- package/src/core/tags.ts +116 -0
- package/src/core/url.ts +58 -0
- package/src/core/uuid.ts +13 -0
- package/src/index.ts +23 -0
- package/src/notification.ts +113 -0
- package/src/reactNativePlatform.ts +172 -0
- package/src/types.ts +174 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const SDK_NAME = 'qpush-react-native';
|
|
2
|
+
export const SDK_VERSION_NUMBER = '1.0.0';
|
|
3
|
+
/** Value of the `SDK-Version` header and of `subscription.sdk`. */
|
|
4
|
+
export const SDK_VERSION = `${SDK_NAME}/${SDK_VERSION_NUMBER}`;
|
|
5
|
+
|
|
6
|
+
/** Keys shared with native code (SharedPreferences / UserDefaults + App Group). */
|
|
7
|
+
export const NativeKeys = {
|
|
8
|
+
appId: 'app_id',
|
|
9
|
+
serverUrl: 'server_url',
|
|
10
|
+
subscriptionId: 'subscription_id',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
/** Keys used by the TypeScript layer for its own persisted state. */
|
|
14
|
+
export const StorageKeys = {
|
|
15
|
+
state: 'qpush_ts_state_v1',
|
|
16
|
+
queue: 'qpush_ts_queue_v1',
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/** Events emitted by the native module. */
|
|
20
|
+
export const NativeEventNames = {
|
|
21
|
+
click: 'QPushPushClick',
|
|
22
|
+
foregroundWillDisplay: 'QPushPushForegroundWillDisplay',
|
|
23
|
+
pushToken: 'QPushPushToken',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/** A new session starts after this much time in background. */
|
|
27
|
+
export const SESSION_TIMEOUT_MS = 30_000;
|
|
28
|
+
/** Max time to wait for the native token before syncing the subscription anyway. */
|
|
29
|
+
export const PUSH_REGISTRATION_TIMEOUT_MS = 20_000;
|
|
30
|
+
/** Max time to keep processing the queue after the app goes to background (iOS). */
|
|
31
|
+
export const BACKGROUND_FLUSH_TIMEOUT_MS = 20_000;
|
|
32
|
+
/** Max number of click events buffered until a listener is attached. */
|
|
33
|
+
export const MAX_PENDING_CLICKS = 50;
|
|
34
|
+
|
|
35
|
+
/** `notification_types` values from the protocol. */
|
|
36
|
+
export const NotificationTypes = {
|
|
37
|
+
subscribed: 1,
|
|
38
|
+
notAsked: 0,
|
|
39
|
+
unsubscribed: -2,
|
|
40
|
+
missingPlayServices: -3,
|
|
41
|
+
apnsFailed: -10,
|
|
42
|
+
} as const;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Minimal typed event hub (no dependency on Node's EventEmitter). */
|
|
2
|
+
export class EventHub<Events extends { [K in keyof Events]: unknown }> {
|
|
3
|
+
private readonly listeners: { [K in keyof Events]?: Set<(payload: Events[K]) => void> } = {};
|
|
4
|
+
|
|
5
|
+
add<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void {
|
|
6
|
+
let set = this.listeners[event];
|
|
7
|
+
if (!set) {
|
|
8
|
+
set = new Set();
|
|
9
|
+
this.listeners[event] = set;
|
|
10
|
+
}
|
|
11
|
+
set.add(listener);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
remove<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void {
|
|
15
|
+
this.listeners[event]?.delete(listener);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
count<K extends keyof Events>(event: K): number {
|
|
19
|
+
return this.listeners[event]?.size ?? 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Calls every listener synchronously; a throwing listener does not stop the others. */
|
|
23
|
+
emit<K extends keyof Events>(event: K, payload: Events[K], onError?: (error: unknown) => void): void {
|
|
24
|
+
const set = this.listeners[event];
|
|
25
|
+
if (!set) return;
|
|
26
|
+
for (const listener of Array.from(set)) {
|
|
27
|
+
try {
|
|
28
|
+
listener(payload);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
onError?.(error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/core/http.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { SDK_VERSION } from './constants';
|
|
2
|
+
import { Logger } from './logger';
|
|
3
|
+
import type { ExecutionResult } from './operationQueue';
|
|
4
|
+
import type { FetchLike, Scheduler } from './platform';
|
|
5
|
+
import { joinUrl } from './url';
|
|
6
|
+
|
|
7
|
+
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
8
|
+
|
|
9
|
+
export interface HttpResponse<T = unknown> {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
/** HTTP status, `0` for network failures / timeouts. */
|
|
12
|
+
status: number;
|
|
13
|
+
data?: T;
|
|
14
|
+
/** First `errors[].code` of an error response. */
|
|
15
|
+
errorCode?: string;
|
|
16
|
+
/** First `errors[].title` of an error response. */
|
|
17
|
+
errorMessage?: string;
|
|
18
|
+
retryAfterMs?: number;
|
|
19
|
+
networkError?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HttpClientOptions {
|
|
23
|
+
fetch: FetchLike;
|
|
24
|
+
getBaseUrl: () => string | undefined;
|
|
25
|
+
getSubscriptionId: () => string | undefined;
|
|
26
|
+
scheduler: Scheduler;
|
|
27
|
+
logger: Logger;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
sdkVersion?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 429 and 5xx are retryable (protocol), as are network failures (status 0). */
|
|
34
|
+
export function isRetryableStatus(status: number): boolean {
|
|
35
|
+
return status === 0 || status === 429 || status >= 500;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parses a `Retry-After` header (seconds or HTTP date). */
|
|
39
|
+
export function parseRetryAfter(value: string | null | undefined, now: number = Date.now()): number | undefined {
|
|
40
|
+
if (!value) return undefined;
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) return Math.round(parseFloat(trimmed) * 1000);
|
|
43
|
+
const date = Date.parse(trimmed);
|
|
44
|
+
if (Number.isNaN(date)) return undefined;
|
|
45
|
+
return Math.max(0, date - now);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Maps a failed (or successful) response to a queue execution result. */
|
|
49
|
+
export function classifyResponse(response: HttpResponse): ExecutionResult {
|
|
50
|
+
if (response.ok) return { type: 'success' };
|
|
51
|
+
if (response.status === 0) return { type: 'retry', countAttempt: false };
|
|
52
|
+
if (isRetryableStatus(response.status)) return { type: 'retry', retryAfterMs: response.retryAfterMs };
|
|
53
|
+
const detail = response.errorCode ?? response.errorMessage ?? '';
|
|
54
|
+
return { type: 'drop', reason: `HTTP ${response.status}${detail ? ` (${detail})` : ''}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ErrorBody {
|
|
58
|
+
errors?: { code?: string; title?: string }[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class HttpClient {
|
|
62
|
+
private readonly timeoutMs: number;
|
|
63
|
+
private readonly sdkVersion: string;
|
|
64
|
+
private readonly now: () => number;
|
|
65
|
+
|
|
66
|
+
constructor(private readonly options: HttpClientOptions) {
|
|
67
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
68
|
+
this.sdkVersion = options.sdkVersion ?? SDK_VERSION;
|
|
69
|
+
this.now = options.now ?? Date.now;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
headers(): Record<string, string> {
|
|
73
|
+
const headers: Record<string, string> = {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
Accept: 'application/json',
|
|
76
|
+
'SDK-Version': this.sdkVersion,
|
|
77
|
+
};
|
|
78
|
+
const subscriptionId = this.options.getSubscriptionId();
|
|
79
|
+
if (subscriptionId) headers['QPush-Subscription-Id'] = subscriptionId;
|
|
80
|
+
return headers;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async request<T = unknown>(method: HttpMethod, path: string, body?: unknown): Promise<HttpResponse<T>> {
|
|
84
|
+
const base = this.options.getBaseUrl();
|
|
85
|
+
if (!base) {
|
|
86
|
+
return { ok: false, status: 0, networkError: true, errorMessage: 'SDK not initialized' };
|
|
87
|
+
}
|
|
88
|
+
const url = joinUrl(base, path);
|
|
89
|
+
const init = {
|
|
90
|
+
method,
|
|
91
|
+
headers: this.headers(),
|
|
92
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
93
|
+
signal: undefined as unknown,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let controller: AbortController | undefined;
|
|
97
|
+
if (typeof AbortController !== 'undefined') {
|
|
98
|
+
controller = new AbortController();
|
|
99
|
+
init.signal = controller.signal;
|
|
100
|
+
}
|
|
101
|
+
let timedOut = false;
|
|
102
|
+
const timer = this.options.scheduler.setTimeout(() => {
|
|
103
|
+
timedOut = true;
|
|
104
|
+
controller?.abort();
|
|
105
|
+
}, this.timeoutMs);
|
|
106
|
+
|
|
107
|
+
this.options.logger.verbose(`HTTP ${method} ${url}`, init.body ?? '');
|
|
108
|
+
try {
|
|
109
|
+
const response = await this.options.fetch(url, init);
|
|
110
|
+
const text = await response.text();
|
|
111
|
+
let parsed: unknown;
|
|
112
|
+
if (text) {
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(text);
|
|
115
|
+
} catch {
|
|
116
|
+
parsed = undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const ok = response.status >= 200 && response.status < 300;
|
|
120
|
+
const result: HttpResponse<T> = { ok, status: response.status };
|
|
121
|
+
if (ok) {
|
|
122
|
+
result.data = parsed as T;
|
|
123
|
+
} else {
|
|
124
|
+
const errors = (parsed as ErrorBody | undefined)?.errors;
|
|
125
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
126
|
+
result.errorCode = errors[0]?.code;
|
|
127
|
+
result.errorMessage = errors[0]?.title;
|
|
128
|
+
}
|
|
129
|
+
result.retryAfterMs = parseRetryAfter(response.headers?.get?.('Retry-After') ?? null, this.now());
|
|
130
|
+
this.options.logger.debug(`HTTP ${method} ${path} -> ${response.status}`, text);
|
|
131
|
+
}
|
|
132
|
+
this.options.logger.verbose(`HTTP ${method} ${path} -> ${response.status}`);
|
|
133
|
+
return result;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.options.logger.debug(`HTTP ${method} ${path} failed${timedOut ? ' (timeout)' : ''}`, error);
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
status: 0,
|
|
139
|
+
networkError: true,
|
|
140
|
+
errorMessage: timedOut ? 'timeout' : String(error),
|
|
141
|
+
};
|
|
142
|
+
} finally {
|
|
143
|
+
this.options.scheduler.clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { LogLevel } from '../types';
|
|
2
|
+
|
|
3
|
+
type ConsoleLike = Pick<Console, 'error' | 'warn' | 'info' | 'log'>;
|
|
4
|
+
|
|
5
|
+
/** Tiny leveled logger. Default level is `Warn`, like OneSignal. */
|
|
6
|
+
export class Logger {
|
|
7
|
+
private level: LogLevel = LogLevel.Warn;
|
|
8
|
+
|
|
9
|
+
constructor(private readonly sink: ConsoleLike = console, private readonly prefix = '[QPush]') {}
|
|
10
|
+
|
|
11
|
+
setLevel(level: LogLevel): void {
|
|
12
|
+
this.level = level;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getLevel(): LogLevel {
|
|
16
|
+
return this.level;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fatal(message: string, ...args: unknown[]): void {
|
|
20
|
+
if (this.level >= LogLevel.Fatal) this.sink.error(`${this.prefix} ${message}`, ...args);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
error(message: string, ...args: unknown[]): void {
|
|
24
|
+
if (this.level >= LogLevel.Error) this.sink.error(`${this.prefix} ${message}`, ...args);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
warn(message: string, ...args: unknown[]): void {
|
|
28
|
+
if (this.level >= LogLevel.Warn) this.sink.warn(`${this.prefix} ${message}`, ...args);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
info(message: string, ...args: unknown[]): void {
|
|
32
|
+
if (this.level >= LogLevel.Info) this.sink.info(`${this.prefix} ${message}`, ...args);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
debug(message: string, ...args: unknown[]): void {
|
|
36
|
+
if (this.level >= LogLevel.Debug) this.sink.log(`${this.prefix} ${message}`, ...args);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
verbose(message: string, ...args: unknown[]): void {
|
|
40
|
+
if (this.level >= LogLevel.Verbose) this.sink.log(`${this.prefix} ${message}`, ...args);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { Logger } from './logger';
|
|
2
|
+
import { isValidOperation, mergeOperations, type Operation } from './operations';
|
|
3
|
+
import { defaultScheduler, type Scheduler } from './platform';
|
|
4
|
+
import { readJson, SerialWriter, type KeyValueStore } from './storage';
|
|
5
|
+
|
|
6
|
+
export interface QueueItem {
|
|
7
|
+
id: string;
|
|
8
|
+
operation: Operation;
|
|
9
|
+
/** Failed attempts that count towards `maxAttempts` (429 / 5xx). */
|
|
10
|
+
attempts: number;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ExecutionResult =
|
|
15
|
+
| { type: 'success' }
|
|
16
|
+
/**
|
|
17
|
+
* Retry later with exponential backoff. `countAttempt: false` is used for
|
|
18
|
+
* network failures (offline): they are retried forever with backoff so
|
|
19
|
+
* offline changes are delivered later, instead of being dropped.
|
|
20
|
+
*/
|
|
21
|
+
| { type: 'retry'; retryAfterMs?: number; countAttempt?: boolean }
|
|
22
|
+
| { type: 'drop'; reason?: string };
|
|
23
|
+
|
|
24
|
+
export interface OperationQueueOptions {
|
|
25
|
+
store: KeyValueStore;
|
|
26
|
+
storageKey: string;
|
|
27
|
+
execute: (operation: Operation, item: QueueItem) => Promise<ExecutionResult>;
|
|
28
|
+
/** Max counted attempts before an operation is dropped (default 5). */
|
|
29
|
+
maxAttempts?: number;
|
|
30
|
+
baseDelayMs?: number;
|
|
31
|
+
maxDelayMs?: number;
|
|
32
|
+
/** Processing only happens while this returns true. */
|
|
33
|
+
canProcess?: () => boolean;
|
|
34
|
+
onDrop?: (item: QueueItem, reason: string) => void;
|
|
35
|
+
scheduler?: Scheduler;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
random?: () => number;
|
|
38
|
+
logger?: Logger;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BackoffOptions {
|
|
42
|
+
baseDelayMs: number;
|
|
43
|
+
maxDelayMs: number;
|
|
44
|
+
/** 0..1, adds up to ±25% jitter. Pass 0.5 for no jitter. */
|
|
45
|
+
random: number;
|
|
46
|
+
retryAfterMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exponential backoff: base * 2^(level-1), capped at max, ±25% jitter.
|
|
51
|
+
* A server provided `Retry-After` wins when it is larger.
|
|
52
|
+
*/
|
|
53
|
+
export function computeBackoffDelay(level: number, options: BackoffOptions): number {
|
|
54
|
+
const exponent = Math.max(0, level - 1);
|
|
55
|
+
const raw = Math.min(options.maxDelayMs, options.baseDelayMs * Math.pow(2, exponent));
|
|
56
|
+
const jitterFactor = 0.75 + Math.min(1, Math.max(0, options.random)) * 0.5;
|
|
57
|
+
let delay = Math.round(raw * jitterFactor);
|
|
58
|
+
if (options.retryAfterMs !== undefined && options.retryAfterMs > delay) {
|
|
59
|
+
delay = options.retryAfterMs;
|
|
60
|
+
}
|
|
61
|
+
return Math.min(Math.max(delay, 0), Math.max(options.maxDelayMs, options.retryAfterMs ?? 0));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Persisted, ordered operation queue.
|
|
66
|
+
*
|
|
67
|
+
* - Operations run strictly one at a time, in order (head-of-line blocking is
|
|
68
|
+
* intended: later operations may depend on earlier ones).
|
|
69
|
+
* - Every change is persisted, so pending operations survive restarts.
|
|
70
|
+
* - Adjacent compatible operations are merged (see `mergeOperations`).
|
|
71
|
+
* - 429 / 5xx are retried with exponential backoff up to `maxAttempts`;
|
|
72
|
+
* network failures are retried without limit; other errors drop the item.
|
|
73
|
+
*/
|
|
74
|
+
export class OperationQueue {
|
|
75
|
+
private items: QueueItem[] = [];
|
|
76
|
+
private loaded = false;
|
|
77
|
+
private inFlightId: string | null = null;
|
|
78
|
+
private running: Promise<void> | null = null;
|
|
79
|
+
private retryTimer: unknown = null;
|
|
80
|
+
private retryAt: number | null = null;
|
|
81
|
+
/** Consecutive failures of the head item (drives the backoff delay). */
|
|
82
|
+
private backoffLevel = 0;
|
|
83
|
+
private sequence = 0;
|
|
84
|
+
private readonly writer: SerialWriter;
|
|
85
|
+
private readonly scheduler: Scheduler;
|
|
86
|
+
private readonly now: () => number;
|
|
87
|
+
private readonly random: () => number;
|
|
88
|
+
private readonly logger: Logger;
|
|
89
|
+
private readonly maxAttempts: number;
|
|
90
|
+
private readonly baseDelayMs: number;
|
|
91
|
+
private readonly maxDelayMs: number;
|
|
92
|
+
|
|
93
|
+
constructor(private readonly options: OperationQueueOptions) {
|
|
94
|
+
this.scheduler = options.scheduler ?? defaultScheduler;
|
|
95
|
+
this.now = options.now ?? Date.now;
|
|
96
|
+
this.random = options.random ?? Math.random;
|
|
97
|
+
this.logger = options.logger ?? new Logger();
|
|
98
|
+
this.maxAttempts = options.maxAttempts ?? 5;
|
|
99
|
+
this.baseDelayMs = options.baseDelayMs ?? 1000;
|
|
100
|
+
this.maxDelayMs = options.maxDelayMs ?? 5 * 60_000;
|
|
101
|
+
this.writer = new SerialWriter(options.store, options.storageKey, (error) =>
|
|
102
|
+
this.logger.error('Failed to persist the operation queue', error),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Loads persisted items. Items enqueued before `load()` are kept after them. */
|
|
107
|
+
async load(): Promise<void> {
|
|
108
|
+
if (this.loaded) return;
|
|
109
|
+
const persisted = await readJson<unknown>(this.options.store, this.options.storageKey);
|
|
110
|
+
const restored: QueueItem[] = [];
|
|
111
|
+
if (Array.isArray(persisted)) {
|
|
112
|
+
for (const entry of persisted) {
|
|
113
|
+
if (
|
|
114
|
+
entry &&
|
|
115
|
+
typeof entry === 'object' &&
|
|
116
|
+
typeof (entry as QueueItem).id === 'string' &&
|
|
117
|
+
isValidOperation((entry as QueueItem).operation)
|
|
118
|
+
) {
|
|
119
|
+
const item = entry as QueueItem;
|
|
120
|
+
restored.push({
|
|
121
|
+
id: item.id,
|
|
122
|
+
operation: item.operation,
|
|
123
|
+
attempts: typeof item.attempts === 'number' ? item.attempts : 0,
|
|
124
|
+
createdAt: typeof item.createdAt === 'number' ? item.createdAt : this.now(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const early = this.items;
|
|
130
|
+
this.items = restored;
|
|
131
|
+
this.loaded = true;
|
|
132
|
+
for (const item of early) this.append(item.operation, false);
|
|
133
|
+
if (restored.length > 0) this.logger.debug(`Restored ${restored.length} pending operation(s)`);
|
|
134
|
+
this.persist();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get isLoaded(): boolean {
|
|
138
|
+
return this.loaded;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Snapshot of pending items (head first). */
|
|
142
|
+
get pending(): readonly QueueItem[] {
|
|
143
|
+
return this.items.slice();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get size(): number {
|
|
147
|
+
return this.items.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Id of the item currently executing, if any. */
|
|
151
|
+
get inFlight(): string | null {
|
|
152
|
+
return this.inFlightId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Time of the next scheduled retry, if the queue is backing off. */
|
|
156
|
+
get nextRetryAt(): number | null {
|
|
157
|
+
return this.retryAt;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
has(predicate: (operation: Operation, item: QueueItem) => boolean): boolean {
|
|
161
|
+
return this.items.some((item) => predicate(item.operation, item));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Adds an operation (merged into the tail when possible) and processes. */
|
|
165
|
+
enqueue(operation: Operation): void {
|
|
166
|
+
this.append(operation, true);
|
|
167
|
+
this.process();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Removes pending (not in-flight) items matching the predicate. */
|
|
171
|
+
remove(predicate: (operation: Operation, item: QueueItem) => boolean): number {
|
|
172
|
+
const before = this.items.length;
|
|
173
|
+
this.items = this.items.filter((item) => item.id === this.inFlightId || !predicate(item.operation, item));
|
|
174
|
+
const removed = before - this.items.length;
|
|
175
|
+
if (removed > 0) this.persist();
|
|
176
|
+
return removed;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Drops everything that is not in flight. */
|
|
180
|
+
clear(): void {
|
|
181
|
+
this.remove(() => true);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Cancels any backoff wait and processes immediately. */
|
|
185
|
+
kick(): void {
|
|
186
|
+
if (this.retryTimer !== null) {
|
|
187
|
+
this.scheduler.clearTimeout(this.retryTimer);
|
|
188
|
+
this.retryTimer = null;
|
|
189
|
+
this.retryAt = null;
|
|
190
|
+
}
|
|
191
|
+
this.process();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Resolves when the current processing run is over (empty, blocked or backing off). */
|
|
195
|
+
async idle(): Promise<void> {
|
|
196
|
+
while (this.running) await this.running;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Resolves when all pending writes reached the store. */
|
|
200
|
+
persisted(): Promise<void> {
|
|
201
|
+
return this.writer.flush();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Starts a processing run if possible. Returns the run promise. */
|
|
205
|
+
process(): Promise<void> {
|
|
206
|
+
if (this.running) return this.running;
|
|
207
|
+
if (!this.loaded || this.retryTimer !== null || this.items.length === 0) return Promise.resolve();
|
|
208
|
+
if (this.options.canProcess && !this.options.canProcess()) return Promise.resolve();
|
|
209
|
+
const run: Promise<void> = this.runLoop().then(
|
|
210
|
+
() => this.afterRun(run),
|
|
211
|
+
(error) => {
|
|
212
|
+
this.logger.error('Operation queue run failed', error);
|
|
213
|
+
this.afterRun(run);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
this.running = run;
|
|
217
|
+
return run;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private afterRun(run: Promise<void>): void {
|
|
221
|
+
if (this.running === run) this.running = null;
|
|
222
|
+
// Items enqueued while the loop was finishing must not be stranded.
|
|
223
|
+
if (
|
|
224
|
+
this.items.length > 0 &&
|
|
225
|
+
this.retryTimer === null &&
|
|
226
|
+
(!this.options.canProcess || this.options.canProcess())
|
|
227
|
+
) {
|
|
228
|
+
void this.process();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private append(operation: Operation, persist: boolean): void {
|
|
233
|
+
const tail = this.items[this.items.length - 1];
|
|
234
|
+
if (operation.kind === 'syncSubscription') {
|
|
235
|
+
// State based: one pending (not in-flight) sync is always enough.
|
|
236
|
+
const pendingSync = this.items.some(
|
|
237
|
+
(item) => item.operation.kind === 'syncSubscription' && item.id !== this.inFlightId,
|
|
238
|
+
);
|
|
239
|
+
if (pendingSync) return;
|
|
240
|
+
}
|
|
241
|
+
if (tail && this.loaded && tail.id !== this.inFlightId) {
|
|
242
|
+
const merged = mergeOperations(tail.operation, operation);
|
|
243
|
+
if (merged) {
|
|
244
|
+
tail.operation = merged;
|
|
245
|
+
if (persist) this.persist();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this.items.push({ id: this.nextId(), operation, attempts: 0, createdAt: this.now() });
|
|
250
|
+
if (persist && this.loaded) this.persist();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private nextId(): string {
|
|
254
|
+
this.sequence += 1;
|
|
255
|
+
return `${this.now().toString(36)}-${this.sequence.toString(36)}-${Math.floor(this.random() * 1e9).toString(36)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private persist(): void {
|
|
259
|
+
if (!this.loaded) return;
|
|
260
|
+
void this.writer.write(JSON.stringify(this.items));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async runLoop(): Promise<void> {
|
|
264
|
+
while (this.items.length > 0) {
|
|
265
|
+
if (this.options.canProcess && !this.options.canProcess()) return;
|
|
266
|
+
const item = this.items[0]!;
|
|
267
|
+
this.inFlightId = item.id;
|
|
268
|
+
let result: ExecutionResult;
|
|
269
|
+
try {
|
|
270
|
+
result = await this.options.execute(item.operation, item);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
this.logger.error(`Operation ${item.operation.kind} threw, dropping it`, error);
|
|
273
|
+
result = { type: 'drop', reason: 'exception' };
|
|
274
|
+
} finally {
|
|
275
|
+
this.inFlightId = null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (result.type === 'success' || result.type === 'drop') {
|
|
279
|
+
this.removeById(item.id);
|
|
280
|
+
this.backoffLevel = 0;
|
|
281
|
+
if (result.type === 'drop') {
|
|
282
|
+
const reason = result.reason ?? 'not retryable';
|
|
283
|
+
this.logger.warn(`Dropped operation ${item.operation.kind}: ${reason}`);
|
|
284
|
+
this.options.onDrop?.(item, reason);
|
|
285
|
+
}
|
|
286
|
+
this.persist();
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Retry.
|
|
291
|
+
if (result.countAttempt !== false) item.attempts += 1;
|
|
292
|
+
if (item.attempts >= this.maxAttempts) {
|
|
293
|
+
this.removeById(item.id);
|
|
294
|
+
this.backoffLevel = 0;
|
|
295
|
+
const reason = `gave up after ${item.attempts} attempts`;
|
|
296
|
+
this.logger.warn(`Dropped operation ${item.operation.kind}: ${reason}`);
|
|
297
|
+
this.options.onDrop?.(item, reason);
|
|
298
|
+
this.persist();
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
this.backoffLevel += 1;
|
|
302
|
+
this.persist();
|
|
303
|
+
const delay = computeBackoffDelay(this.backoffLevel, {
|
|
304
|
+
baseDelayMs: this.baseDelayMs,
|
|
305
|
+
maxDelayMs: this.maxDelayMs,
|
|
306
|
+
random: this.random(),
|
|
307
|
+
retryAfterMs: result.retryAfterMs,
|
|
308
|
+
});
|
|
309
|
+
this.logger.debug(`Retrying ${item.operation.kind} in ${delay}ms`);
|
|
310
|
+
this.retryAt = this.now() + delay;
|
|
311
|
+
this.retryTimer = this.scheduler.setTimeout(() => {
|
|
312
|
+
this.retryTimer = null;
|
|
313
|
+
this.retryAt = null;
|
|
314
|
+
void this.process();
|
|
315
|
+
}, delay);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private removeById(id: string): void {
|
|
321
|
+
const index = this.items.findIndex((entry) => entry.id === id);
|
|
322
|
+
if (index >= 0) this.items.splice(index, 1);
|
|
323
|
+
}
|
|
324
|
+
}
|