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 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";
@@ -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
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"pubsub.d.ts","sourceRoot":"","sources":["../src/pubsub.ts"],"names":[],"mappings":"AAEA,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;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC,CAAC,CAAC,CAyC3E"}
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
- // In local dev, deliver directly to subscribers
17
- // In production, publish to NATS/SNS/Pub/Sub
18
- const natsUrl = process.env.COOPER_NATS_URL ?? "nats://localhost:4222";
19
- // Direct local delivery for development
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
@@ -1 +1 @@
1
- {"version":3,"file":"pubsub.js","sourceRoot":"","sources":["../src/pubsub.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAiBzC;;;;;;;;;GASG;AACH,MAAM,UAAU,KAAK,CAAU,IAAY,EAAE,MAAoB;IAC/D,MAAM,WAAW,GAAG,IAAI,GAAG,EAA+C,CAAC;IAE3E,MAAM,CAAC,GAAa;QAClB,KAAK,CAAC,OAAO,CAAC,IAAO;YACnB,gDAAgD;YAChD,6CAA6C;YAC7C,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,uBAAuB,CAAC;YAEvE,wCAAwC;YACxC,KAAK,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,WAAW,EAAE,CAAC;gBACzC,IAAI,CAAC;oBACH,MAAM,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;gBAC1B,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,OAAO,CAAC,KAAK,CAAC,wBAAwB,OAAO,WAAW,EAAE,GAAG,CAAC,CAAC;gBACjE,CAAC;YACH,CAAC;QACH,CAAC;QAED,SAAS,CAAC,OAAe,EAAE,SAA0B;YACnD,WAAW,CAAC,GAAG,CAAC,OAAO,EAAE;gBACvB,OAAO,EAAE,SAAS,CAAC,OAAO;gBAC1B,OAAO,EAAE,SAAS;aACnB,CAAC,CAAC;YAEH,QAAQ,CAAC,aAAa,CAAC,IAAI,EAAE;gBAC3B,IAAI;gBACJ,WAAW;aACZ,CAAC,CAAC;YAEH,OAAO;gBACL,YAAY,EAAE,cAAc;gBAC5B,KAAK,EAAE,IAAI;gBACX,IAAI,EAAE,OAAO;aACd,CAAC;QACJ,CAAC;KACF,CAAC;IAEF,QAAQ,CAAC,aAAa,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC;IAEpD,OAAO,CAAC,CAAC;AACX,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",
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
- // In local dev, deliver directly to subscribers
34
- // In production, publish to NATS/SNS/Pub/Sub
35
- const natsUrl = process.env.COOPER_NATS_URL ?? "nats://localhost:4222";
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
- // Direct local delivery for development
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
+ }