@doeza/sms-service 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/AGENTS.md +1616 -0
- package/README.md +139 -0
- package/examples/README.md +4 -0
- package/examples/nextjs-supabase/.env.example +10 -0
- package/examples/nextjs-supabase/README.md +211 -0
- package/examples/nextjs-supabase/src/app/api/sms/callbacks/reply/route.ts +12 -0
- package/examples/nextjs-supabase/src/app/api/sms/callbacks/status/route.ts +12 -0
- package/examples/nextjs-supabase/src/app/api/sms/send/route.ts +133 -0
- package/examples/nextjs-supabase/src/lib/sms/repository.ts +250 -0
- package/examples/nextjs-supabase/src/lib/sms/send-request.ts +56 -0
- package/examples/nextjs-supabase/src/lib/sms/service.ts +43 -0
- package/examples/nextjs-supabase/src/lib/sms/webhook-auth.ts +19 -0
- package/examples/nextjs-supabase/src/lib/supabase/admin.ts +25 -0
- package/examples/nextjs-supabase/src/lib/supabase/server.ts +28 -0
- package/examples/nextjs-supabase/supabase/schema.sql +399 -0
- package/examples/send.mjs +18 -0
- package/http.d.ts +9 -0
- package/http.js +75 -0
- package/index.d.ts +70 -0
- package/index.js +176 -0
- package/package.json +1 -0
package/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export class SmsError extends Error {
|
|
4
|
+
constructor(code, message, details = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "SmsError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.httpStatus = details.httpStatus;
|
|
9
|
+
this.clientMessageId = details.clientMessageId;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
const invalid = (message) => { throw new SmsError("VALIDATION", message); };
|
|
15
|
+
|
|
16
|
+
export function normalizeRecipients(to) {
|
|
17
|
+
const values = typeof to === "string" ? to.split(",") : to;
|
|
18
|
+
if (!Array.isArray(values) || values.length === 0) invalid("At least one recipient is required.");
|
|
19
|
+
const numbers = values.map((value) => {
|
|
20
|
+
if (typeof value !== "string") invalid("Recipients must be strings.");
|
|
21
|
+
const number = value.trim().replace(/^\+/, "");
|
|
22
|
+
if (!/^[1-9]\d{4,14}$/.test(number)) {
|
|
23
|
+
invalid("Use international phone numbers with 5–15 digits, including the country code.");
|
|
24
|
+
}
|
|
25
|
+
return number;
|
|
26
|
+
});
|
|
27
|
+
return [...new Set(numbers)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function callbackUrl(value) {
|
|
31
|
+
if (value === undefined) return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) throw new Error();
|
|
35
|
+
return url.href;
|
|
36
|
+
} catch {
|
|
37
|
+
throw new SmsError("CONFIGURATION", "Callback URLs must be absolute HTTP(S) URLs without credentials or fragments.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseResponse(body, to, clientMessageId, httpStatus) {
|
|
42
|
+
const fail = () => { throw new SmsError("INVALID_RESPONSE", "The SMS provider returned an invalid response. Check message status before retrying.", { httpStatus, clientMessageId }); };
|
|
43
|
+
if (!object(body)) fail();
|
|
44
|
+
// Clickatell may reject a request inside a successful HTTP response.
|
|
45
|
+
if (body.error || (body.responseCode !== undefined && (!Number.isInteger(body.responseCode) || body.responseCode >= 400))) {
|
|
46
|
+
throw new SmsError("PROVIDER", "The SMS provider rejected the request.", { httpStatus, clientMessageId });
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(body.messages) || body.messages.length !== to.length) fail();
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
const messages = body.messages.map((entry) => {
|
|
51
|
+
if (!object(entry) || typeof entry.to !== "string" || typeof entry.accepted !== "boolean") fail();
|
|
52
|
+
const recipient = entry.to.replace(/^\+/, "");
|
|
53
|
+
if (!to.includes(recipient) || seen.has(recipient)) fail();
|
|
54
|
+
seen.add(recipient);
|
|
55
|
+
if (entry.accepted && (typeof entry.apiMessageId !== "string" || !entry.apiMessageId.trim())) fail();
|
|
56
|
+
const result = { to: recipient, accepted: entry.accepted };
|
|
57
|
+
if (typeof entry.apiMessageId === "string") result.apiMessageId = entry.apiMessageId;
|
|
58
|
+
const errorCode = entry.errorCode ?? entry.error?.code;
|
|
59
|
+
if (typeof errorCode === "string" || typeof errorCode === "number") result.errorCode = errorCode;
|
|
60
|
+
return result;
|
|
61
|
+
});
|
|
62
|
+
const accepted = messages.filter((message) => message.accepted).length;
|
|
63
|
+
return {
|
|
64
|
+
success: accepted === to.length,
|
|
65
|
+
partial: accepted > 0 && accepted < to.length,
|
|
66
|
+
clientMessageId,
|
|
67
|
+
to,
|
|
68
|
+
...(body.responseCode === undefined ? {} : { responseCode: body.responseCode }),
|
|
69
|
+
messages,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createSmsService(options) {
|
|
74
|
+
if (!object(options) || typeof options.apiKey !== "string" || !options.apiKey.trim()) {
|
|
75
|
+
throw new SmsError("CONFIGURATION", "A Clickatell API key is required.");
|
|
76
|
+
}
|
|
77
|
+
const apiKey = options.apiKey.trim();
|
|
78
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
79
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
|
|
80
|
+
throw new SmsError("CONFIGURATION", "timeoutMs must be a positive integer no greater than 2147483647.");
|
|
81
|
+
}
|
|
82
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
83
|
+
if (typeof fetchImpl !== "function") throw new SmsError("CONFIGURATION", "A Fetch API implementation is required.");
|
|
84
|
+
const statusCallbackUrl = callbackUrl(options.statusCallbackUrl);
|
|
85
|
+
const replyCallbackUrl = callbackUrl(options.replyCallbackUrl);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async send(input, { signal } = {}) {
|
|
89
|
+
if (!object(input)) invalid("An SMS request object is required.");
|
|
90
|
+
const to = normalizeRecipients(input.to);
|
|
91
|
+
if (typeof input.content !== "string" || !input.content.trim()) invalid("Message content cannot be empty.");
|
|
92
|
+
if (input.clientMessageId !== undefined && (typeof input.clientMessageId !== "string" || !input.clientMessageId.trim())) {
|
|
93
|
+
invalid("clientMessageId must be a non-empty string.");
|
|
94
|
+
}
|
|
95
|
+
const clientMessageId = input.clientMessageId ?? randomUUID();
|
|
96
|
+
const details = { clientMessageId };
|
|
97
|
+
if (signal?.aborted) throw new SmsError("ABORTED", "The SMS request was cancelled.", details);
|
|
98
|
+
const url = new URL("https://platform.clickatell.com/messages/http/send");
|
|
99
|
+
url.search = new URLSearchParams({ apiKey, to: to.join(","), content: input.content, clientMessageId }).toString();
|
|
100
|
+
if (statusCallbackUrl) url.searchParams.set("callback", statusCallbackUrl);
|
|
101
|
+
if (replyCallbackUrl) url.searchParams.set("replyCallback", replyCallbackUrl);
|
|
102
|
+
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const cancel = () => controller.abort();
|
|
105
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
106
|
+
let timedOut = false;
|
|
107
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetchImpl(url.href, {
|
|
110
|
+
method: "GET", cache: "no-store", redirect: "error",
|
|
111
|
+
headers: { Accept: "application/json" }, signal: controller.signal,
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
await response.body?.cancel();
|
|
115
|
+
throw new SmsError("PROVIDER", `The SMS provider returned HTTP ${response.status}.`, { ...details, httpStatus: response.status });
|
|
116
|
+
}
|
|
117
|
+
const text = await response.text();
|
|
118
|
+
if (controller.signal.aborted) throw new Error();
|
|
119
|
+
let body;
|
|
120
|
+
try { body = JSON.parse(text); } catch {
|
|
121
|
+
throw new SmsError("INVALID_RESPONSE", "The SMS provider returned invalid JSON. Check message status before retrying.", details);
|
|
122
|
+
}
|
|
123
|
+
return parseResponse(body, to, clientMessageId, response.status);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (timedOut) throw new SmsError("TIMEOUT", "The SMS request timed out. Check message status before retrying.", details);
|
|
126
|
+
if (signal?.aborted) throw new SmsError("ABORTED", "The SMS request was cancelled. Check message status before retrying.", details);
|
|
127
|
+
if (error instanceof SmsError) throw error;
|
|
128
|
+
// Native fetch errors can contain the credential-bearing URL. Do not expose them.
|
|
129
|
+
throw new SmsError("NETWORK", "Could not contact the SMS provider. Check message status before retrying.", details);
|
|
130
|
+
} finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
signal?.removeEventListener("abort", cancel);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function stringField(input, name, optional = false) {
|
|
139
|
+
const value = input[name];
|
|
140
|
+
if (optional && value === undefined) return undefined;
|
|
141
|
+
if (typeof value !== "string" || !value.trim()) invalid(`${name} must be a non-empty string.`);
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function integerField(input, name, optional = false) {
|
|
146
|
+
const value = input[name];
|
|
147
|
+
if (optional && value === undefined) return undefined;
|
|
148
|
+
if ((typeof value !== "number" && typeof value !== "string") || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) {
|
|
149
|
+
invalid(`${name} must be a non-negative integer.`);
|
|
150
|
+
}
|
|
151
|
+
return Number(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function optionalStrings(input, names) {
|
|
155
|
+
return Object.fromEntries(names.filter((name) => input[name] !== undefined).map((name) => [name, stringField(input, name, true)]));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function parseStatusCallback(input) {
|
|
159
|
+
if (!object(input)) invalid("A status callback object is required.");
|
|
160
|
+
return {
|
|
161
|
+
...optionalStrings(input, ["integrationName", "requestId", "clientMessageId", "from", "statusDescription"]),
|
|
162
|
+
messageId: stringField(input, "messageId"), to: stringField(input, "to"),
|
|
163
|
+
statusCode: integerField(input, "statusCode"), status: stringField(input, "status"),
|
|
164
|
+
timestamp: stringField(input, "timestamp"),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function parseReplyCallback(input) {
|
|
169
|
+
if (!object(input)) invalid("A reply callback object is required.");
|
|
170
|
+
return {
|
|
171
|
+
...optionalStrings(input, ["integrationName", "replyMessageId", "messageId", "charset", "udh", "keyword"]),
|
|
172
|
+
fromNumber: stringField(input, "fromNumber"), toNumber: stringField(input, "toNumber"),
|
|
173
|
+
timestamp: integerField(input, "timestamp"), text: stringField(input, "text"),
|
|
174
|
+
...(input.network === undefined ? {} : { network: integerField(input, "network") }),
|
|
175
|
+
};
|
|
176
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"@doeza/sms-service","version":"1.0.0","description":"Standalone server-side Clickatell SMS service with typed results and Web Request handlers","type":"module","main":"./index.js","types":"./index.d.ts","exports":{".":{"types":"./index.d.ts","import":"./index.js","default":"./index.js"},"./http":{"types":"./http.d.ts","import":"./http.js","default":"./http.js"}},"files":["*.js","*.d.ts","README.md","AGENTS.md","examples"],"engines":{"node":">=22"},"scripts":{"test":"node --test test/*.test.js"}}
|