cooper-stack 0.5.3 → 0.5.4
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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/nats.d.ts +34 -0
- package/dist/nats.d.ts.map +1 -0
- package/dist/nats.js +104 -0
- package/dist/nats.js.map +1 -0
- package/dist/pubsub.d.ts +3 -0
- package/dist/pubsub.d.ts.map +1 -1
- package/dist/pubsub.js +108 -4
- package/dist/pubsub.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +1 -0
- package/src/nats.ts +128 -0
- package/src/pubsub.ts +143 -4
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { database, type DatabaseClient } from "./db.js";
|
|
|
4
4
|
export { middleware, cooper } from "./middleware.js";
|
|
5
5
|
export { authHandler } from "./auth.js";
|
|
6
6
|
export { topic, type Topic } from "./pubsub.js";
|
|
7
|
+
export { closeNats } from "./nats.js";
|
|
7
8
|
export { cron } from "./cron.js";
|
|
8
9
|
export { cache, type CacheClient } from "./cache.js";
|
|
9
10
|
export { bucket, type BucketClient } from "./storage.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/B,OAAO,EAAE,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAE,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,KAAK,EAAE,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/B,OAAO,EAAE,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAE,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,KAAK,EAAE,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { database } from "./db.js";
|
|
|
4
4
|
export { middleware, cooper } from "./middleware.js";
|
|
5
5
|
export { authHandler } from "./auth.js";
|
|
6
6
|
export { topic } from "./pubsub.js";
|
|
7
|
+
export { closeNats } from "./nats.js";
|
|
7
8
|
export { cron } from "./cron.js";
|
|
8
9
|
export { cache } from "./cache.js";
|
|
9
10
|
export { bucket } from "./storage.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/B,OAAO,EAAE,WAAW,EAAkB,MAAM,YAAY,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAuB,MAAM,SAAS,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,KAAK,EAAc,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAoB,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,MAAM,EAAqB,MAAM,cAAc,CAAC;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,EAAoB,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAC/B,OAAO,EAAE,WAAW,EAAkB,MAAM,YAAY,CAAC;AACzD,OAAO,EAAE,QAAQ,EAAuB,MAAM,SAAS,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,KAAK,EAAc,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,KAAK,EAAoB,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,MAAM,EAAqB,MAAM,cAAc,CAAC;AACzD,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,EAAoB,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/nats.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS Connection Manager — singleton lazy connection to embedded NATS.
|
|
3
|
+
*
|
|
4
|
+
* JetStream is used for durable pub/sub with delivery guarantees.
|
|
5
|
+
* Falls back gracefully if NATS is unavailable (logs warning once).
|
|
6
|
+
*/
|
|
7
|
+
import { type NatsConnection, type JetStreamClient, type JetStreamManager } from "nats";
|
|
8
|
+
declare const jc: import("nats").Codec<unknown>;
|
|
9
|
+
export declare function ensureConnected(): Promise<boolean>;
|
|
10
|
+
export declare function getJetStream(): JetStreamClient | null;
|
|
11
|
+
export declare function getJetStreamManager(): JetStreamManager | null;
|
|
12
|
+
export declare function getConnection(): NatsConnection | null;
|
|
13
|
+
export { jc as jsonCodec };
|
|
14
|
+
/**
|
|
15
|
+
* Sanitize a topic name into a valid NATS stream name.
|
|
16
|
+
* NATS streams: alphanumeric + dash + underscore only.
|
|
17
|
+
*/
|
|
18
|
+
export declare function streamName(topicName: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Sanitize a subscriber name into a valid NATS durable consumer name.
|
|
21
|
+
*/
|
|
22
|
+
export declare function consumerName(subscriberName: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Ensure a JetStream stream exists for a topic.
|
|
25
|
+
* Creates it if missing, no-ops if it already exists.
|
|
26
|
+
*/
|
|
27
|
+
export declare function ensureStream(topicName: string, config?: {
|
|
28
|
+
dedup?: boolean;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Graceful shutdown — drain and close the connection.
|
|
32
|
+
*/
|
|
33
|
+
export declare function closeNats(): Promise<void>;
|
|
34
|
+
//# sourceMappingURL=nats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nats.d.ts","sourceRoot":"","sources":["../src/nats.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EAKtB,MAAM,MAAM,CAAC;AAQd,QAAA,MAAM,EAAE,+BAAc,CAAC;AA0BvB,wBAAsB,eAAe,IAAI,OAAO,CAAC,OAAO,CAAC,CAOxD;AAED,wBAAgB,YAAY,IAAI,eAAe,GAAG,IAAI,CAErD;AAED,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,IAAI,CAE7D;AAED,wBAAgB,aAAa,IAAI,cAAc,GAAG,IAAI,CAErD;AAED,OAAO,EAAE,EAAE,IAAI,SAAS,EAAE,CAAC;AAE3B;;;GAGG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAC3B,OAAO,CAAC,IAAI,CAAC,CAuBf;AAED;;GAEG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAI/C"}
|
package/dist/nats.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS Connection Manager — singleton lazy connection to embedded NATS.
|
|
3
|
+
*
|
|
4
|
+
* JetStream is used for durable pub/sub with delivery guarantees.
|
|
5
|
+
* Falls back gracefully if NATS is unavailable (logs warning once).
|
|
6
|
+
*/
|
|
7
|
+
import { connect, JSONCodec, RetentionPolicy, StorageType, } from "nats";
|
|
8
|
+
let nc = null;
|
|
9
|
+
let js = null;
|
|
10
|
+
let jsm = null;
|
|
11
|
+
let connectPromise = null;
|
|
12
|
+
let warnedOnce = false;
|
|
13
|
+
const jc = JSONCodec();
|
|
14
|
+
function getNatsUrl() {
|
|
15
|
+
return process.env.COOPER_NATS_URL ?? "nats://localhost:4222";
|
|
16
|
+
}
|
|
17
|
+
async function doConnect() {
|
|
18
|
+
try {
|
|
19
|
+
nc = await connect({ servers: getNatsUrl(), maxReconnectAttempts: 5 });
|
|
20
|
+
js = nc.jetstream();
|
|
21
|
+
jsm = await nc.jetstreamManager();
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (!warnedOnce) {
|
|
26
|
+
console.warn(`[cooper] NATS unavailable at ${getNatsUrl()} — pub/sub will use in-memory fallback. ${err.message}`);
|
|
27
|
+
warnedOnce = true;
|
|
28
|
+
}
|
|
29
|
+
nc = null;
|
|
30
|
+
js = null;
|
|
31
|
+
jsm = null;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function ensureConnected() {
|
|
36
|
+
if (nc && !nc.isClosed())
|
|
37
|
+
return true;
|
|
38
|
+
if (connectPromise)
|
|
39
|
+
return connectPromise;
|
|
40
|
+
connectPromise = doConnect().finally(() => {
|
|
41
|
+
connectPromise = null;
|
|
42
|
+
});
|
|
43
|
+
return connectPromise;
|
|
44
|
+
}
|
|
45
|
+
export function getJetStream() {
|
|
46
|
+
return js;
|
|
47
|
+
}
|
|
48
|
+
export function getJetStreamManager() {
|
|
49
|
+
return jsm;
|
|
50
|
+
}
|
|
51
|
+
export function getConnection() {
|
|
52
|
+
return nc;
|
|
53
|
+
}
|
|
54
|
+
export { jc as jsonCodec };
|
|
55
|
+
/**
|
|
56
|
+
* Sanitize a topic name into a valid NATS stream name.
|
|
57
|
+
* NATS streams: alphanumeric + dash + underscore only.
|
|
58
|
+
*/
|
|
59
|
+
export function streamName(topicName) {
|
|
60
|
+
return "COOPER_" + topicName.replace(/[^a-zA-Z0-9_-]/g, "_").toUpperCase();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Sanitize a subscriber name into a valid NATS durable consumer name.
|
|
64
|
+
*/
|
|
65
|
+
export function consumerName(subscriberName) {
|
|
66
|
+
return subscriberName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ensure a JetStream stream exists for a topic.
|
|
70
|
+
* Creates it if missing, no-ops if it already exists.
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureStream(topicName, config) {
|
|
73
|
+
if (!jsm)
|
|
74
|
+
return;
|
|
75
|
+
const name = streamName(topicName);
|
|
76
|
+
const subject = `cooper.topic.${topicName}`;
|
|
77
|
+
try {
|
|
78
|
+
await jsm.streams.info(name);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
await jsm.streams.add({
|
|
82
|
+
name,
|
|
83
|
+
subjects: [subject],
|
|
84
|
+
retention: RetentionPolicy.Interest,
|
|
85
|
+
max_msgs: -1,
|
|
86
|
+
max_bytes: -1,
|
|
87
|
+
max_age: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days in nanos
|
|
88
|
+
storage: StorageType.File,
|
|
89
|
+
num_replicas: 1,
|
|
90
|
+
duplicate_window: config?.dedup
|
|
91
|
+
? 2 * 60 * 1_000_000_000 // 2 min dedup window
|
|
92
|
+
: 0,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Graceful shutdown — drain and close the connection.
|
|
98
|
+
*/
|
|
99
|
+
export async function closeNats() {
|
|
100
|
+
if (nc && !nc.isClosed()) {
|
|
101
|
+
await nc.drain();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=nats.js.map
|
package/dist/nats.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nats.js","sourceRoot":"","sources":["../src/nats.ts"],"names":[],"mappings":"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"}
|
package/dist/pubsub.d.ts
CHANGED
|
@@ -19,6 +19,9 @@ export interface Topic<T> {
|
|
|
19
19
|
* { deliveryGuarantee: "at-least-once" }
|
|
20
20
|
* );
|
|
21
21
|
* ```
|
|
22
|
+
*
|
|
23
|
+
* Local dev uses embedded NATS with JetStream for durable delivery.
|
|
24
|
+
* Falls back to in-memory if NATS is unavailable.
|
|
22
25
|
*/
|
|
23
26
|
export declare function topic<T = any>(name: string, config?: TopicConfig): Topic<T>;
|
|
24
27
|
//# sourceMappingURL=pubsub.d.ts.map
|
package/dist/pubsub.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pubsub.d.ts","sourceRoot":"","sources":["../src/pubsub.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"pubsub.d.ts","sourceRoot":"","sources":["../src/pubsub.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,WAAW;IAC1B,iBAAiB,CAAC,EAAE,eAAe,GAAG,cAAc,CAAC;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,MAAM,WAAW,KAAK,CAAC,CAAC;IACtB,OAAO,CAAC,IAAI,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,GAAG,CAAC;CACvD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC,CAAC,CAAC,CA2I3E"}
|
package/dist/pubsub.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { registry } from "./registry.js";
|
|
2
|
+
import { ensureConnected, getJetStream, getJetStreamManager, jsonCodec, streamName, consumerName, ensureStream, } from "./nats.js";
|
|
3
|
+
import { headers as natsHeaders, AckPolicy } from "nats";
|
|
2
4
|
/**
|
|
3
5
|
* Declare a typed Pub/Sub topic.
|
|
4
6
|
*
|
|
@@ -8,15 +10,84 @@ import { registry } from "./registry.js";
|
|
|
8
10
|
* { deliveryGuarantee: "at-least-once" }
|
|
9
11
|
* );
|
|
10
12
|
* ```
|
|
13
|
+
*
|
|
14
|
+
* Local dev uses embedded NATS with JetStream for durable delivery.
|
|
15
|
+
* Falls back to in-memory if NATS is unavailable.
|
|
11
16
|
*/
|
|
12
17
|
export function topic(name, config) {
|
|
13
18
|
const subscribers = new Map();
|
|
19
|
+
const useDedup = config?.deliveryGuarantee === "exactly-once";
|
|
20
|
+
const subject = `cooper.topic.${name}`;
|
|
21
|
+
// Track whether JetStream consumers have been started
|
|
22
|
+
const activeConsumers = new Set();
|
|
23
|
+
/**
|
|
24
|
+
* Start a JetStream pull consumer for a subscriber.
|
|
25
|
+
* Runs in the background, processing messages until the connection closes.
|
|
26
|
+
*/
|
|
27
|
+
async function startConsumer(subName, handler, concurrency) {
|
|
28
|
+
if (activeConsumers.has(subName))
|
|
29
|
+
return;
|
|
30
|
+
activeConsumers.add(subName);
|
|
31
|
+
const js = getJetStream();
|
|
32
|
+
const jsm = getJetStreamManager();
|
|
33
|
+
if (!js || !jsm)
|
|
34
|
+
return;
|
|
35
|
+
const stream = streamName(name);
|
|
36
|
+
const durable = consumerName(subName);
|
|
37
|
+
// Ensure consumer exists
|
|
38
|
+
try {
|
|
39
|
+
await jsm.consumers.info(stream, durable);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
await jsm.consumers.add(stream, {
|
|
43
|
+
durable_name: durable,
|
|
44
|
+
ack_policy: AckPolicy.Explicit,
|
|
45
|
+
max_ack_pending: concurrency,
|
|
46
|
+
filter_subject: subject,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const consumer = await js.consumers.get(stream, durable);
|
|
50
|
+
// Process messages in the background
|
|
51
|
+
(async () => {
|
|
52
|
+
try {
|
|
53
|
+
const messages = await consumer.consume();
|
|
54
|
+
for await (const msg of messages) {
|
|
55
|
+
try {
|
|
56
|
+
const data = jsonCodec.decode(msg.data);
|
|
57
|
+
await handler(data);
|
|
58
|
+
msg.ack();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
console.error(`[cooper] Subscriber "${subName}" on topic "${name}" failed:`, err);
|
|
62
|
+
// NAK with delay for retry (5 second backoff)
|
|
63
|
+
msg.nak(5000);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// Consumer iteration ended (connection closed, etc.)
|
|
69
|
+
activeConsumers.delete(subName);
|
|
70
|
+
if (!err.message?.includes("closed")) {
|
|
71
|
+
console.error(`[cooper] Consumer "${subName}" on topic "${name}" stopped:`, err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
}
|
|
14
76
|
const t = {
|
|
15
77
|
async publish(data) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
78
|
+
const connected = await ensureConnected();
|
|
79
|
+
if (connected) {
|
|
80
|
+
const js = getJetStream();
|
|
81
|
+
if (js) {
|
|
82
|
+
await ensureStream(name, { dedup: useDedup });
|
|
83
|
+
const headers = useDedup && data && typeof data === "object"
|
|
84
|
+
? createDedup(data, config?.orderBy)
|
|
85
|
+
: undefined;
|
|
86
|
+
await js.publish(subject, jsonCodec.encode(data), { headers });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Fallback: in-memory delivery
|
|
20
91
|
for (const [subName, sub] of subscribers) {
|
|
21
92
|
try {
|
|
22
93
|
await sub.handler(data);
|
|
@@ -35,6 +106,16 @@ export function topic(name, config) {
|
|
|
35
106
|
name,
|
|
36
107
|
subscribers,
|
|
37
108
|
});
|
|
109
|
+
// Start JetStream consumer in the background
|
|
110
|
+
const concurrency = subConfig.concurrency ?? 1;
|
|
111
|
+
ensureConnected().then(async (connected) => {
|
|
112
|
+
if (connected) {
|
|
113
|
+
await ensureStream(name, { dedup: useDedup });
|
|
114
|
+
startConsumer(subName, subConfig.handler, concurrency).catch((err) => {
|
|
115
|
+
console.error(`[cooper] Failed to start consumer "${subName}":`, err);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
38
119
|
return {
|
|
39
120
|
_cooper_type: "subscription",
|
|
40
121
|
topic: name,
|
|
@@ -45,4 +126,27 @@ export function topic(name, config) {
|
|
|
45
126
|
registry.registerTopic(name, { name, subscribers });
|
|
46
127
|
return t;
|
|
47
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Create NATS headers for exactly-once dedup.
|
|
131
|
+
* Uses Nats-Msg-Id header — JetStream deduplicates within the stream's
|
|
132
|
+
* duplicate_window based on this ID.
|
|
133
|
+
*/
|
|
134
|
+
function createDedup(data, orderBy) {
|
|
135
|
+
const h = natsHeaders();
|
|
136
|
+
// Generate a deterministic dedup ID.
|
|
137
|
+
// If an ordering key is set, use that field's value as the dedup key.
|
|
138
|
+
// Otherwise, hash the entire payload for content-based dedup.
|
|
139
|
+
if (orderBy && data[orderBy] !== undefined) {
|
|
140
|
+
h.set("Nats-Msg-Id", `${orderBy}-${String(data[orderBy])}`);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const payload = JSON.stringify(data);
|
|
144
|
+
let hash = 0;
|
|
145
|
+
for (let i = 0; i < payload.length; i++) {
|
|
146
|
+
hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
|
|
147
|
+
}
|
|
148
|
+
h.set("Nats-Msg-Id", `msg-${Math.abs(hash)}`);
|
|
149
|
+
}
|
|
150
|
+
return h;
|
|
151
|
+
}
|
|
48
152
|
//# sourceMappingURL=pubsub.js.map
|
package/dist/pubsub.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pubsub.js","sourceRoot":"","sources":["../src/pubsub.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"pubsub.js","sourceRoot":"","sources":["../src/pubsub.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cooper-stack",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "The backend framework for TypeScript",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"pg": "^8.13.0",
|
|
30
30
|
"mysql2": "^3.11.0",
|
|
31
|
-
"ioredis": "^5.4.0"
|
|
31
|
+
"ioredis": "^5.4.0",
|
|
32
|
+
"nats": "^2.29.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"typescript": "^5.7.0",
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { database, type DatabaseClient } from "./db.js";
|
|
|
4
4
|
export { middleware, cooper } from "./middleware.js";
|
|
5
5
|
export { authHandler } from "./auth.js";
|
|
6
6
|
export { topic, type Topic } from "./pubsub.js";
|
|
7
|
+
export { closeNats } from "./nats.js";
|
|
7
8
|
export { cron } from "./cron.js";
|
|
8
9
|
export { cache, type CacheClient } from "./cache.js";
|
|
9
10
|
export { bucket, type BucketClient } from "./storage.js";
|
package/src/nats.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS Connection Manager — singleton lazy connection to embedded NATS.
|
|
3
|
+
*
|
|
4
|
+
* JetStream is used for durable pub/sub with delivery guarantees.
|
|
5
|
+
* Falls back gracefully if NATS is unavailable (logs warning once).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
connect,
|
|
10
|
+
type NatsConnection,
|
|
11
|
+
type JetStreamClient,
|
|
12
|
+
type JetStreamManager,
|
|
13
|
+
JSONCodec,
|
|
14
|
+
RetentionPolicy,
|
|
15
|
+
StorageType,
|
|
16
|
+
AckPolicy,
|
|
17
|
+
} from "nats";
|
|
18
|
+
|
|
19
|
+
let nc: NatsConnection | null = null;
|
|
20
|
+
let js: JetStreamClient | null = null;
|
|
21
|
+
let jsm: JetStreamManager | null = null;
|
|
22
|
+
let connectPromise: Promise<boolean> | null = null;
|
|
23
|
+
let warnedOnce = false;
|
|
24
|
+
|
|
25
|
+
const jc = JSONCodec();
|
|
26
|
+
|
|
27
|
+
function getNatsUrl(): string {
|
|
28
|
+
return process.env.COOPER_NATS_URL ?? "nats://localhost:4222";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function doConnect(): Promise<boolean> {
|
|
32
|
+
try {
|
|
33
|
+
nc = await connect({ servers: getNatsUrl(), maxReconnectAttempts: 5 });
|
|
34
|
+
js = nc.jetstream();
|
|
35
|
+
jsm = await nc.jetstreamManager();
|
|
36
|
+
return true;
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
if (!warnedOnce) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[cooper] NATS unavailable at ${getNatsUrl()} — pub/sub will use in-memory fallback. ${err.message}`
|
|
41
|
+
);
|
|
42
|
+
warnedOnce = true;
|
|
43
|
+
}
|
|
44
|
+
nc = null;
|
|
45
|
+
js = null;
|
|
46
|
+
jsm = null;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensureConnected(): Promise<boolean> {
|
|
52
|
+
if (nc && !nc.isClosed()) return true;
|
|
53
|
+
if (connectPromise) return connectPromise;
|
|
54
|
+
connectPromise = doConnect().finally(() => {
|
|
55
|
+
connectPromise = null;
|
|
56
|
+
});
|
|
57
|
+
return connectPromise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getJetStream(): JetStreamClient | null {
|
|
61
|
+
return js;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getJetStreamManager(): JetStreamManager | null {
|
|
65
|
+
return jsm;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getConnection(): NatsConnection | null {
|
|
69
|
+
return nc;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { jc as jsonCodec };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sanitize a topic name into a valid NATS stream name.
|
|
76
|
+
* NATS streams: alphanumeric + dash + underscore only.
|
|
77
|
+
*/
|
|
78
|
+
export function streamName(topicName: string): string {
|
|
79
|
+
return "COOPER_" + topicName.replace(/[^a-zA-Z0-9_-]/g, "_").toUpperCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Sanitize a subscriber name into a valid NATS durable consumer name.
|
|
84
|
+
*/
|
|
85
|
+
export function consumerName(subscriberName: string): string {
|
|
86
|
+
return subscriberName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Ensure a JetStream stream exists for a topic.
|
|
91
|
+
* Creates it if missing, no-ops if it already exists.
|
|
92
|
+
*/
|
|
93
|
+
export async function ensureStream(
|
|
94
|
+
topicName: string,
|
|
95
|
+
config?: { dedup?: boolean }
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
if (!jsm) return;
|
|
98
|
+
|
|
99
|
+
const name = streamName(topicName);
|
|
100
|
+
const subject = `cooper.topic.${topicName}`;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await jsm.streams.info(name);
|
|
104
|
+
} catch {
|
|
105
|
+
await jsm.streams.add({
|
|
106
|
+
name,
|
|
107
|
+
subjects: [subject],
|
|
108
|
+
retention: RetentionPolicy.Interest,
|
|
109
|
+
max_msgs: -1,
|
|
110
|
+
max_bytes: -1,
|
|
111
|
+
max_age: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days in nanos
|
|
112
|
+
storage: StorageType.File,
|
|
113
|
+
num_replicas: 1,
|
|
114
|
+
duplicate_window: config?.dedup
|
|
115
|
+
? 2 * 60 * 1_000_000_000 // 2 min dedup window
|
|
116
|
+
: 0,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Graceful shutdown — drain and close the connection.
|
|
123
|
+
*/
|
|
124
|
+
export async function closeNats(): Promise<void> {
|
|
125
|
+
if (nc && !nc.isClosed()) {
|
|
126
|
+
await nc.drain();
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/pubsub.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { registry } from "./registry.js";
|
|
2
|
+
import {
|
|
3
|
+
ensureConnected,
|
|
4
|
+
getJetStream,
|
|
5
|
+
getJetStreamManager,
|
|
6
|
+
jsonCodec,
|
|
7
|
+
streamName,
|
|
8
|
+
consumerName,
|
|
9
|
+
ensureStream,
|
|
10
|
+
} from "./nats.js";
|
|
11
|
+
import { headers as natsHeaders, AckPolicy } from "nats";
|
|
2
12
|
|
|
3
13
|
export interface TopicConfig {
|
|
4
14
|
deliveryGuarantee?: "at-least-once" | "exactly-once";
|
|
@@ -24,17 +34,102 @@ export interface Topic<T> {
|
|
|
24
34
|
* { deliveryGuarantee: "at-least-once" }
|
|
25
35
|
* );
|
|
26
36
|
* ```
|
|
37
|
+
*
|
|
38
|
+
* Local dev uses embedded NATS with JetStream for durable delivery.
|
|
39
|
+
* Falls back to in-memory if NATS is unavailable.
|
|
27
40
|
*/
|
|
28
41
|
export function topic<T = any>(name: string, config?: TopicConfig): Topic<T> {
|
|
29
42
|
const subscribers = new Map<string, { handler: Function; options: any }>();
|
|
43
|
+
const useDedup = config?.deliveryGuarantee === "exactly-once";
|
|
44
|
+
const subject = `cooper.topic.${name}`;
|
|
45
|
+
|
|
46
|
+
// Track whether JetStream consumers have been started
|
|
47
|
+
const activeConsumers = new Set<string>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start a JetStream pull consumer for a subscriber.
|
|
51
|
+
* Runs in the background, processing messages until the connection closes.
|
|
52
|
+
*/
|
|
53
|
+
async function startConsumer(
|
|
54
|
+
subName: string,
|
|
55
|
+
handler: Function,
|
|
56
|
+
concurrency: number
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
if (activeConsumers.has(subName)) return;
|
|
59
|
+
activeConsumers.add(subName);
|
|
60
|
+
|
|
61
|
+
const js = getJetStream();
|
|
62
|
+
const jsm = getJetStreamManager();
|
|
63
|
+
if (!js || !jsm) return;
|
|
64
|
+
|
|
65
|
+
const stream = streamName(name);
|
|
66
|
+
const durable = consumerName(subName);
|
|
67
|
+
|
|
68
|
+
// Ensure consumer exists
|
|
69
|
+
try {
|
|
70
|
+
await jsm.consumers.info(stream, durable);
|
|
71
|
+
} catch {
|
|
72
|
+
await jsm.consumers.add(stream, {
|
|
73
|
+
durable_name: durable,
|
|
74
|
+
ack_policy: AckPolicy.Explicit,
|
|
75
|
+
max_ack_pending: concurrency,
|
|
76
|
+
filter_subject: subject,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const consumer = await js.consumers.get(stream, durable);
|
|
81
|
+
|
|
82
|
+
// Process messages in the background
|
|
83
|
+
(async () => {
|
|
84
|
+
try {
|
|
85
|
+
const messages = await consumer.consume();
|
|
86
|
+
for await (const msg of messages) {
|
|
87
|
+
try {
|
|
88
|
+
const data = jsonCodec.decode(msg.data);
|
|
89
|
+
await handler(data);
|
|
90
|
+
msg.ack();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(
|
|
93
|
+
`[cooper] Subscriber "${subName}" on topic "${name}" failed:`,
|
|
94
|
+
err
|
|
95
|
+
);
|
|
96
|
+
// NAK with delay for retry (5 second backoff)
|
|
97
|
+
msg.nak(5000);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
// Consumer iteration ended (connection closed, etc.)
|
|
102
|
+
activeConsumers.delete(subName);
|
|
103
|
+
if (!err.message?.includes("closed")) {
|
|
104
|
+
console.error(
|
|
105
|
+
`[cooper] Consumer "${subName}" on topic "${name}" stopped:`,
|
|
106
|
+
err
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
}
|
|
30
112
|
|
|
31
113
|
const t: Topic<T> = {
|
|
32
114
|
async publish(data: T) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
115
|
+
const connected = await ensureConnected();
|
|
116
|
+
|
|
117
|
+
if (connected) {
|
|
118
|
+
const js = getJetStream();
|
|
119
|
+
if (js) {
|
|
120
|
+
await ensureStream(name, { dedup: useDedup });
|
|
36
121
|
|
|
37
|
-
|
|
122
|
+
const headers =
|
|
123
|
+
useDedup && data && typeof data === "object"
|
|
124
|
+
? createDedup(data as any, config?.orderBy)
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
await js.publish(subject, jsonCodec.encode(data), { headers });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Fallback: in-memory delivery
|
|
38
133
|
for (const [subName, sub] of subscribers) {
|
|
39
134
|
try {
|
|
40
135
|
await sub.handler(data);
|
|
@@ -55,6 +150,22 @@ export function topic<T = any>(name: string, config?: TopicConfig): Topic<T> {
|
|
|
55
150
|
subscribers,
|
|
56
151
|
});
|
|
57
152
|
|
|
153
|
+
// Start JetStream consumer in the background
|
|
154
|
+
const concurrency = subConfig.concurrency ?? 1;
|
|
155
|
+
ensureConnected().then(async (connected) => {
|
|
156
|
+
if (connected) {
|
|
157
|
+
await ensureStream(name, { dedup: useDedup });
|
|
158
|
+
startConsumer(subName, subConfig.handler, concurrency).catch(
|
|
159
|
+
(err) => {
|
|
160
|
+
console.error(
|
|
161
|
+
`[cooper] Failed to start consumer "${subName}":`,
|
|
162
|
+
err
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
58
169
|
return {
|
|
59
170
|
_cooper_type: "subscription",
|
|
60
171
|
topic: name,
|
|
@@ -67,3 +178,31 @@ export function topic<T = any>(name: string, config?: TopicConfig): Topic<T> {
|
|
|
67
178
|
|
|
68
179
|
return t;
|
|
69
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Create NATS headers for exactly-once dedup.
|
|
184
|
+
* Uses Nats-Msg-Id header — JetStream deduplicates within the stream's
|
|
185
|
+
* duplicate_window based on this ID.
|
|
186
|
+
*/
|
|
187
|
+
function createDedup(
|
|
188
|
+
data: Record<string, any>,
|
|
189
|
+
orderBy?: string
|
|
190
|
+
): any {
|
|
191
|
+
const h = natsHeaders();
|
|
192
|
+
|
|
193
|
+
// Generate a deterministic dedup ID.
|
|
194
|
+
// If an ordering key is set, use that field's value as the dedup key.
|
|
195
|
+
// Otherwise, hash the entire payload for content-based dedup.
|
|
196
|
+
if (orderBy && data[orderBy] !== undefined) {
|
|
197
|
+
h.set("Nats-Msg-Id", `${orderBy}-${String(data[orderBy])}`);
|
|
198
|
+
} else {
|
|
199
|
+
const payload = JSON.stringify(data);
|
|
200
|
+
let hash = 0;
|
|
201
|
+
for (let i = 0; i < payload.length; i++) {
|
|
202
|
+
hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
|
|
203
|
+
}
|
|
204
|
+
h.set("Nats-Msg-Id", `msg-${Math.abs(hash)}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return h;
|
|
208
|
+
}
|