@rei-standard/amsg-client 2.0.1 → 2.2.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 CHANGED
@@ -38,6 +38,61 @@ await client.scheduleMessage({
38
38
  });
39
39
  ```
40
40
 
41
+ ## 发送即时消息
42
+
43
+ 新代码用 `client.sendInstant(payload)`,走 [`@rei-standard/amsg-instant`](https://github.com/Tosd0/ReiStandard/blob/main/packages/rei-standard-amsg/instant/README.md)。
44
+
45
+ ### 加密模式(默认;兼容 amsg-server / amsg-instant 0.1.x)
46
+
47
+ ```js
48
+ const client = new ReiClient({
49
+ baseUrl: '/api/v1',
50
+ customBaseUrls: {
51
+ instant: 'https://instant.example.com', // 不传则用 baseUrl
52
+ },
53
+ userId: '550e8400-e29b-41d4-a716-446655440000',
54
+ });
55
+
56
+ await client.init();
57
+
58
+ await client.sendInstant({
59
+ contactName: 'Rei',
60
+ completePrompt: '你是 Rei,用一句话提醒用户带伞',
61
+ apiUrl: 'https://api.openai.com/v1/chat/completions',
62
+ apiKey: '...',
63
+ primaryModel: 'gpt-4o-mini',
64
+ pushSubscription: subscription.toJSON(),
65
+ });
66
+ ```
67
+
68
+ > `customBaseUrls` 是按端点名(如 `instant`)覆盖 `baseUrl` 的通用机制;后续其他端点也可以用同一字段独立指定 base URL,不会再加新的命名字段。
69
+
70
+ ### 明文模式(配 amsg-instant 0.2.x,单租户自部署)
71
+
72
+ ```js
73
+ const client = new ReiClient({
74
+ baseUrl: 'https://instant.example.com', // amsg-instant Worker URL
75
+ instantEncryption: false,
76
+ instantClientToken: 'shared-secret-xyz', // 可选;Worker 端配了再填
77
+ });
78
+
79
+ // init() 在明文模式下是 no-op,调用与否都跑得通
80
+ await client.sendInstant({
81
+ contactName: 'Rei',
82
+ completePrompt: '你是 Rei,用一句话提醒用户带伞',
83
+ apiUrl: 'https://api.openai.com/v1/chat/completions',
84
+ apiKey: '...',
85
+ primaryModel: 'gpt-4o-mini',
86
+ pushSubscription: subscription.toJSON(),
87
+ });
88
+ ```
89
+
90
+ > ⚠️ **`instantClientToken` 是弱共享密钥**:它会随前端 bundle 发出去,devtools 一开就能看到。它只防 URL 直接被脚本小子打,不防有心人。要真正的鉴权,用 amsg-instant 的 `tokenSigningKey`(HMAC JWT,配合后端签发短期 token)。
91
+
92
+ > ⚠️ **双模式陷阱**:`instantEncryption: false` 时 `init()` 变成 no-op,`scheduleMessage` / `listMessages` / `updateMessage` 这类**仍走加密**的方法会因 `userKey` 没初始化抛 "Not initialised"。如果同一前端既要 `sendInstant`(明文走 amsg-instant)又要 `scheduleMessage`(加密走 amsg-server),请改回 `instantEncryption: true`(默认)—— amsg-instant 0.1.x 与 amsg-server 用同一份 `userKey` 都吃得下。
93
+
94
+ 旧路径 `scheduleMessage({ ...payload, messageType: 'instant' })` 仍然可用(兼容保留,多一次 DB 来回)。
95
+
41
96
  ## 导出 API(Exports)
42
97
 
43
98
  - `ReiClient`
@@ -46,6 +101,7 @@ await client.scheduleMessage({
46
101
 
47
102
  - `init()`
48
103
  - `scheduleMessage(payload)`
104
+ - `sendInstant(payload)`
49
105
  - `updateMessage(uuid, updates)`
50
106
  - `cancelMessage(uuid)`
51
107
  - `listMessages(opts)`
@@ -61,8 +117,8 @@ await client.scheduleMessage({
61
117
 
62
118
  - 浏览器环境(需 `fetch`、`crypto.subtle`)
63
119
  - Push 订阅需可用 Service Worker 与 Push API
64
- - 需要可用的 `baseUrl`(示例:`/api/v1`)
65
- - `userId` 必须是 UUID v4
120
+ - 需要可用的 `baseUrl`(示例:`/api/v1`;明文 instant 模式下可直接是 Worker URL)
121
+ - `userId` 必须是 UUID v4(明文 instant 模式 `instantEncryption: false` 下可省)
66
122
 
67
123
  ## 相关链接(绝对 URL)
68
124
 
package/dist/index.cjs CHANGED
@@ -28,17 +28,51 @@ var ReiClient = class {
28
28
  */
29
29
  constructor(config) {
30
30
  if (!config || !config.baseUrl) throw new Error("[rei-standard-amsg-client] baseUrl is required");
31
- if (!config.userId) throw new Error("[rei-standard-amsg-client] userId is required");
31
+ const instantEncryption = config.instantEncryption !== false;
32
+ if (!config.userId && instantEncryption) {
33
+ throw new Error(
34
+ "[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)"
35
+ );
36
+ }
32
37
  this._baseUrl = config.baseUrl.replace(/\/+$/, "");
33
- this._userId = config.userId;
38
+ this._customBaseUrls = {};
39
+ if (config.customBaseUrls && typeof config.customBaseUrls === "object") {
40
+ for (const [name, url] of Object.entries(config.customBaseUrls)) {
41
+ if (typeof url === "string" && url) {
42
+ this._customBaseUrls[name] = url.replace(/\/+$/, "");
43
+ }
44
+ }
45
+ }
46
+ this._userId = config.userId || "";
34
47
  this._userKey = null;
48
+ this._instantEncryption = instantEncryption;
49
+ this._instantClientToken = typeof config.instantClientToken === "string" && config.instantClientToken ? config.instantClientToken : "";
50
+ }
51
+ /**
52
+ * Resolve the base URL for a given endpoint, falling back to `baseUrl`.
53
+ *
54
+ * @private
55
+ * @param {string} endpointName
56
+ * @returns {string}
57
+ */
58
+ _resolveBaseUrl(endpointName) {
59
+ return this._customBaseUrls[endpointName] || this._baseUrl;
35
60
  }
36
61
  // ─── Initialisation ─────────────────────────────────────────────
37
62
  /**
38
63
  * Fetch the user-specific encryption key.
39
64
  * Must be called before any encrypted request.
65
+ *
66
+ * In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
67
+ * `sendInstant()` does not need a userKey. Note that if you also intend to
68
+ * call `scheduleMessage` / `listMessages` / `updateMessage` (which always
69
+ * use AES-256-GCM), you must construct with `instantEncryption: true`
70
+ * (the default) — those methods will throw "Not initialised" otherwise.
40
71
  */
41
72
  async init() {
73
+ if (this._instantEncryption === false) {
74
+ return;
75
+ }
42
76
  const res = await fetch(`${this._baseUrl}/get-user-key`, {
43
77
  method: "GET",
44
78
  headers: { "X-User-Id": this._userId }
@@ -53,7 +87,14 @@ var ReiClient = class {
53
87
  }
54
88
  // ─── Public API ─────────────────────────────────────────────────
55
89
  /**
56
- * Schedule (or instantly send) a message.
90
+ * Schedule a message.
91
+ *
92
+ * Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
93
+ * That routes through `@rei-standard/amsg-instant` (stateless, no DB
94
+ * round-trip) rather than `amsg-server`'s schedule-message endpoint.
95
+ * This method still works for instant via amsg-server for backward
96
+ * compatibility — see CHANGELOG / README for details.
97
+ *
57
98
  * The payload is automatically encrypted before transmission.
58
99
  *
59
100
  * @param {Object} payload - Schedule message payload.
@@ -73,6 +114,58 @@ var ReiClient = class {
73
114
  });
74
115
  return res.json();
75
116
  }
117
+ /**
118
+ * Send a one-shot instant message via `@rei-standard/amsg-instant`.
119
+ *
120
+ * Compared to `scheduleMessage({ messageType: 'instant', ... })`:
121
+ * - No DB round-trip on the server side (stateless)
122
+ * - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
123
+ * - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
124
+ *
125
+ * Two transport modes (chosen by constructor `instantEncryption`):
126
+ *
127
+ * - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
128
+ * `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
129
+ * with amsg-server's `schedule-message` instant path. Sends
130
+ * `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
131
+ *
132
+ * - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
133
+ * JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
134
+ * `instantClientToken` was configured.
135
+ *
136
+ * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
137
+ *
138
+ * @param {Object} payload - Instant message payload.
139
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
140
+ * @param {{ authorization?: string }} [opts] - Optional auth header to forward.
141
+ * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
142
+ */
143
+ async sendInstant(payload, endpointPath = "/instant", opts = {}) {
144
+ const headers = { "Content-Type": "application/json" };
145
+ let body;
146
+ if (this._instantEncryption === false) {
147
+ body = JSON.stringify(payload);
148
+ if (this._instantClientToken) {
149
+ headers["X-Client-Token"] = this._instantClientToken;
150
+ }
151
+ } else {
152
+ const encrypted = await this._encrypt(JSON.stringify(payload));
153
+ headers["X-User-Id"] = this._userId;
154
+ headers["X-Payload-Encrypted"] = "true";
155
+ headers["X-Encryption-Version"] = "1";
156
+ body = JSON.stringify(encrypted);
157
+ }
158
+ if (opts.authorization) {
159
+ headers["Authorization"] = opts.authorization;
160
+ }
161
+ const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
162
+ const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
163
+ method: "POST",
164
+ headers,
165
+ body
166
+ });
167
+ return res.json();
168
+ }
76
169
  /**
77
170
  * Update an existing scheduled message.
78
171
  *
package/dist/index.d.cts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * ReiStandard Client SDK
3
- * v2.0.1
4
3
  *
5
4
  * Lightweight browser client that handles:
6
- * - AES-256-GCM encryption using the Web Crypto API
5
+ * - AES-256-GCM encryption using the Web Crypto API (for amsg-server's
6
+ * schedule path and amsg-instant 0.1.x)
7
+ * - Optional plaintext mode for amsg-instant 0.2.x (instantEncryption: false)
7
8
  * - Push subscription management via the Push API
8
- * - Convenient request helpers for all 7 endpoints
9
9
  *
10
10
  * Usage:
11
11
  * import { ReiClient } from '@rei-standard/amsg-client';
@@ -24,8 +24,32 @@
24
24
 
25
25
  /**
26
26
  * @typedef {Object} ReiClientConfig
27
- * @property {string} baseUrl - Base URL of the API (e.g. https://host/api/v1).
28
- * @property {string} userId - Current user identifier.
27
+ * @property {string} baseUrl - Default base URL of the API (e.g. https://host/api/v1).
28
+ * In plaintext-instant mode (`instantEncryption: false`)
29
+ * this can be the amsg-instant Worker URL directly.
30
+ * @property {Record<string, string>} [customBaseUrls] - Optional per-endpoint base URL overrides.
31
+ * Key is the endpoint name (e.g. `instant`); value is
32
+ * the base URL to use for that endpoint instead of
33
+ * `baseUrl`. Useful when different endpoints live on
34
+ * different deployments (e.g. `instant` on Cloudflare
35
+ * Workers while the rest run on Netlify). Future
36
+ * endpoints (e.g. `schedule`, `messages`) can be
37
+ * overridden the same way without an API change.
38
+ * @property {string} [userId] - Current user identifier (UUID v4). Required for the
39
+ * encrypted path (default `instantEncryption: true`,
40
+ * and for `scheduleMessage` / `listMessages` /
41
+ * `updateMessage` always). Can be omitted only when
42
+ * `instantEncryption: false` AND you do not call any
43
+ * encrypted method.
44
+ * @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` posts plaintext JSON
45
+ * to amsg-instant 0.2.x. `init()` becomes a no-op.
46
+ * All other methods (`scheduleMessage` etc.) keep
47
+ * using AES-256-GCM regardless of this flag.
48
+ * @property {string} [instantClientToken] - When set, sent as the `X-Client-Token` header by
49
+ * `sendInstant()` in plaintext mode. Note: this is
50
+ * a *weak* shared secret — it ships inside any
51
+ * frontend bundle that uses it, so devtools can
52
+ * read it. Use for casual URL-direct abuse only.
29
53
  */
30
54
 
31
55
  class ReiClient {
@@ -34,14 +58,46 @@ class ReiClient {
34
58
  */
35
59
  constructor(config) {
36
60
  if (!config || !config.baseUrl) throw new Error('[rei-standard-amsg-client] baseUrl is required');
37
- if (!config.userId) throw new Error('[rei-standard-amsg-client] userId is required');
61
+
62
+ const instantEncryption = config.instantEncryption !== false;
63
+ if (!config.userId && instantEncryption) {
64
+ throw new Error(
65
+ '[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)'
66
+ );
67
+ }
38
68
 
39
69
  /** @private */
40
70
  this._baseUrl = config.baseUrl.replace(/\/+$/, '');
41
71
  /** @private */
42
- this._userId = config.userId;
72
+ this._customBaseUrls = {};
73
+ if (config.customBaseUrls && typeof config.customBaseUrls === 'object') {
74
+ for (const [name, url] of Object.entries(config.customBaseUrls)) {
75
+ if (typeof url === 'string' && url) {
76
+ this._customBaseUrls[name] = url.replace(/\/+$/, '');
77
+ }
78
+ }
79
+ }
80
+ /** @private */
81
+ this._userId = config.userId || '';
43
82
  /** @private */
44
83
  this._userKey = null;
84
+ /** @private */
85
+ this._instantEncryption = instantEncryption;
86
+ /** @private */
87
+ this._instantClientToken = typeof config.instantClientToken === 'string' && config.instantClientToken
88
+ ? config.instantClientToken
89
+ : '';
90
+ }
91
+
92
+ /**
93
+ * Resolve the base URL for a given endpoint, falling back to `baseUrl`.
94
+ *
95
+ * @private
96
+ * @param {string} endpointName
97
+ * @returns {string}
98
+ */
99
+ _resolveBaseUrl(endpointName) {
100
+ return this._customBaseUrls[endpointName] || this._baseUrl;
45
101
  }
46
102
 
47
103
  // ─── Initialisation ─────────────────────────────────────────────
@@ -49,8 +105,18 @@ class ReiClient {
49
105
  /**
50
106
  * Fetch the user-specific encryption key.
51
107
  * Must be called before any encrypted request.
108
+ *
109
+ * In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
110
+ * `sendInstant()` does not need a userKey. Note that if you also intend to
111
+ * call `scheduleMessage` / `listMessages` / `updateMessage` (which always
112
+ * use AES-256-GCM), you must construct with `instantEncryption: true`
113
+ * (the default) — those methods will throw "Not initialised" otherwise.
52
114
  */
53
115
  async init() {
116
+ if (this._instantEncryption === false) {
117
+ return;
118
+ }
119
+
54
120
  const res = await fetch(`${this._baseUrl}/get-user-key`, {
55
121
  method: 'GET',
56
122
  headers: { 'X-User-Id': this._userId }
@@ -70,7 +136,14 @@ class ReiClient {
70
136
  // ─── Public API ─────────────────────────────────────────────────
71
137
 
72
138
  /**
73
- * Schedule (or instantly send) a message.
139
+ * Schedule a message.
140
+ *
141
+ * Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
142
+ * That routes through `@rei-standard/amsg-instant` (stateless, no DB
143
+ * round-trip) rather than `amsg-server`'s schedule-message endpoint.
144
+ * This method still works for instant via amsg-server for backward
145
+ * compatibility — see CHANGELOG / README for details.
146
+ *
74
147
  * The payload is automatically encrypted before transmission.
75
148
  *
76
149
  * @param {Object} payload - Schedule message payload.
@@ -93,6 +166,63 @@ class ReiClient {
93
166
  return res.json();
94
167
  }
95
168
 
169
+ /**
170
+ * Send a one-shot instant message via `@rei-standard/amsg-instant`.
171
+ *
172
+ * Compared to `scheduleMessage({ messageType: 'instant', ... })`:
173
+ * - No DB round-trip on the server side (stateless)
174
+ * - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
175
+ * - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
176
+ *
177
+ * Two transport modes (chosen by constructor `instantEncryption`):
178
+ *
179
+ * - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
180
+ * `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
181
+ * with amsg-server's `schedule-message` instant path. Sends
182
+ * `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
183
+ *
184
+ * - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
185
+ * JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
186
+ * `instantClientToken` was configured.
187
+ *
188
+ * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
189
+ *
190
+ * @param {Object} payload - Instant message payload.
191
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
192
+ * @param {{ authorization?: string }} [opts] - Optional auth header to forward.
193
+ * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
194
+ */
195
+ async sendInstant(payload, endpointPath = '/instant', opts = {}) {
196
+ const headers = { 'Content-Type': 'application/json' };
197
+ let body;
198
+
199
+ if (this._instantEncryption === false) {
200
+ body = JSON.stringify(payload);
201
+ if (this._instantClientToken) {
202
+ headers['X-Client-Token'] = this._instantClientToken;
203
+ }
204
+ } else {
205
+ const encrypted = await this._encrypt(JSON.stringify(payload));
206
+ headers['X-User-Id'] = this._userId;
207
+ headers['X-Payload-Encrypted'] = 'true';
208
+ headers['X-Encryption-Version'] = '1';
209
+ body = JSON.stringify(encrypted);
210
+ }
211
+
212
+ if (opts.authorization) {
213
+ headers['Authorization'] = opts.authorization;
214
+ }
215
+
216
+ const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
217
+ const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
218
+ method: 'POST',
219
+ headers,
220
+ body
221
+ });
222
+
223
+ return res.json();
224
+ }
225
+
96
226
  /**
97
227
  * Update an existing scheduled message.
98
228
  *
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * ReiStandard Client SDK
3
- * v2.0.1
4
3
  *
5
4
  * Lightweight browser client that handles:
6
- * - AES-256-GCM encryption using the Web Crypto API
5
+ * - AES-256-GCM encryption using the Web Crypto API (for amsg-server's
6
+ * schedule path and amsg-instant 0.1.x)
7
+ * - Optional plaintext mode for amsg-instant 0.2.x (instantEncryption: false)
7
8
  * - Push subscription management via the Push API
8
- * - Convenient request helpers for all 7 endpoints
9
9
  *
10
10
  * Usage:
11
11
  * import { ReiClient } from '@rei-standard/amsg-client';
@@ -24,8 +24,32 @@
24
24
 
25
25
  /**
26
26
  * @typedef {Object} ReiClientConfig
27
- * @property {string} baseUrl - Base URL of the API (e.g. https://host/api/v1).
28
- * @property {string} userId - Current user identifier.
27
+ * @property {string} baseUrl - Default base URL of the API (e.g. https://host/api/v1).
28
+ * In plaintext-instant mode (`instantEncryption: false`)
29
+ * this can be the amsg-instant Worker URL directly.
30
+ * @property {Record<string, string>} [customBaseUrls] - Optional per-endpoint base URL overrides.
31
+ * Key is the endpoint name (e.g. `instant`); value is
32
+ * the base URL to use for that endpoint instead of
33
+ * `baseUrl`. Useful when different endpoints live on
34
+ * different deployments (e.g. `instant` on Cloudflare
35
+ * Workers while the rest run on Netlify). Future
36
+ * endpoints (e.g. `schedule`, `messages`) can be
37
+ * overridden the same way without an API change.
38
+ * @property {string} [userId] - Current user identifier (UUID v4). Required for the
39
+ * encrypted path (default `instantEncryption: true`,
40
+ * and for `scheduleMessage` / `listMessages` /
41
+ * `updateMessage` always). Can be omitted only when
42
+ * `instantEncryption: false` AND you do not call any
43
+ * encrypted method.
44
+ * @property {boolean} [instantEncryption=true] - When `false`, `sendInstant()` posts plaintext JSON
45
+ * to amsg-instant 0.2.x. `init()` becomes a no-op.
46
+ * All other methods (`scheduleMessage` etc.) keep
47
+ * using AES-256-GCM regardless of this flag.
48
+ * @property {string} [instantClientToken] - When set, sent as the `X-Client-Token` header by
49
+ * `sendInstant()` in plaintext mode. Note: this is
50
+ * a *weak* shared secret — it ships inside any
51
+ * frontend bundle that uses it, so devtools can
52
+ * read it. Use for casual URL-direct abuse only.
29
53
  */
30
54
 
31
55
  class ReiClient {
@@ -34,14 +58,46 @@ class ReiClient {
34
58
  */
35
59
  constructor(config) {
36
60
  if (!config || !config.baseUrl) throw new Error('[rei-standard-amsg-client] baseUrl is required');
37
- if (!config.userId) throw new Error('[rei-standard-amsg-client] userId is required');
61
+
62
+ const instantEncryption = config.instantEncryption !== false;
63
+ if (!config.userId && instantEncryption) {
64
+ throw new Error(
65
+ '[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)'
66
+ );
67
+ }
38
68
 
39
69
  /** @private */
40
70
  this._baseUrl = config.baseUrl.replace(/\/+$/, '');
41
71
  /** @private */
42
- this._userId = config.userId;
72
+ this._customBaseUrls = {};
73
+ if (config.customBaseUrls && typeof config.customBaseUrls === 'object') {
74
+ for (const [name, url] of Object.entries(config.customBaseUrls)) {
75
+ if (typeof url === 'string' && url) {
76
+ this._customBaseUrls[name] = url.replace(/\/+$/, '');
77
+ }
78
+ }
79
+ }
80
+ /** @private */
81
+ this._userId = config.userId || '';
43
82
  /** @private */
44
83
  this._userKey = null;
84
+ /** @private */
85
+ this._instantEncryption = instantEncryption;
86
+ /** @private */
87
+ this._instantClientToken = typeof config.instantClientToken === 'string' && config.instantClientToken
88
+ ? config.instantClientToken
89
+ : '';
90
+ }
91
+
92
+ /**
93
+ * Resolve the base URL for a given endpoint, falling back to `baseUrl`.
94
+ *
95
+ * @private
96
+ * @param {string} endpointName
97
+ * @returns {string}
98
+ */
99
+ _resolveBaseUrl(endpointName) {
100
+ return this._customBaseUrls[endpointName] || this._baseUrl;
45
101
  }
46
102
 
47
103
  // ─── Initialisation ─────────────────────────────────────────────
@@ -49,8 +105,18 @@ class ReiClient {
49
105
  /**
50
106
  * Fetch the user-specific encryption key.
51
107
  * Must be called before any encrypted request.
108
+ *
109
+ * In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
110
+ * `sendInstant()` does not need a userKey. Note that if you also intend to
111
+ * call `scheduleMessage` / `listMessages` / `updateMessage` (which always
112
+ * use AES-256-GCM), you must construct with `instantEncryption: true`
113
+ * (the default) — those methods will throw "Not initialised" otherwise.
52
114
  */
53
115
  async init() {
116
+ if (this._instantEncryption === false) {
117
+ return;
118
+ }
119
+
54
120
  const res = await fetch(`${this._baseUrl}/get-user-key`, {
55
121
  method: 'GET',
56
122
  headers: { 'X-User-Id': this._userId }
@@ -70,7 +136,14 @@ class ReiClient {
70
136
  // ─── Public API ─────────────────────────────────────────────────
71
137
 
72
138
  /**
73
- * Schedule (or instantly send) a message.
139
+ * Schedule a message.
140
+ *
141
+ * Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
142
+ * That routes through `@rei-standard/amsg-instant` (stateless, no DB
143
+ * round-trip) rather than `amsg-server`'s schedule-message endpoint.
144
+ * This method still works for instant via amsg-server for backward
145
+ * compatibility — see CHANGELOG / README for details.
146
+ *
74
147
  * The payload is automatically encrypted before transmission.
75
148
  *
76
149
  * @param {Object} payload - Schedule message payload.
@@ -93,6 +166,63 @@ class ReiClient {
93
166
  return res.json();
94
167
  }
95
168
 
169
+ /**
170
+ * Send a one-shot instant message via `@rei-standard/amsg-instant`.
171
+ *
172
+ * Compared to `scheduleMessage({ messageType: 'instant', ... })`:
173
+ * - No DB round-trip on the server side (stateless)
174
+ * - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
175
+ * - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
176
+ *
177
+ * Two transport modes (chosen by constructor `instantEncryption`):
178
+ *
179
+ * - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
180
+ * `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
181
+ * with amsg-server's `schedule-message` instant path. Sends
182
+ * `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
183
+ *
184
+ * - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
185
+ * JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
186
+ * `instantClientToken` was configured.
187
+ *
188
+ * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
189
+ *
190
+ * @param {Object} payload - Instant message payload.
191
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
192
+ * @param {{ authorization?: string }} [opts] - Optional auth header to forward.
193
+ * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
194
+ */
195
+ async sendInstant(payload, endpointPath = '/instant', opts = {}) {
196
+ const headers = { 'Content-Type': 'application/json' };
197
+ let body;
198
+
199
+ if (this._instantEncryption === false) {
200
+ body = JSON.stringify(payload);
201
+ if (this._instantClientToken) {
202
+ headers['X-Client-Token'] = this._instantClientToken;
203
+ }
204
+ } else {
205
+ const encrypted = await this._encrypt(JSON.stringify(payload));
206
+ headers['X-User-Id'] = this._userId;
207
+ headers['X-Payload-Encrypted'] = 'true';
208
+ headers['X-Encryption-Version'] = '1';
209
+ body = JSON.stringify(encrypted);
210
+ }
211
+
212
+ if (opts.authorization) {
213
+ headers['Authorization'] = opts.authorization;
214
+ }
215
+
216
+ const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
217
+ const res = await fetch(`${this._resolveBaseUrl('instant')}${path}`, {
218
+ method: 'POST',
219
+ headers,
220
+ body
221
+ });
222
+
223
+ return res.json();
224
+ }
225
+
96
226
  /**
97
227
  * Update an existing scheduled message.
98
228
  *
package/dist/index.mjs CHANGED
@@ -5,17 +5,51 @@ var ReiClient = class {
5
5
  */
6
6
  constructor(config) {
7
7
  if (!config || !config.baseUrl) throw new Error("[rei-standard-amsg-client] baseUrl is required");
8
- if (!config.userId) throw new Error("[rei-standard-amsg-client] userId is required");
8
+ const instantEncryption = config.instantEncryption !== false;
9
+ if (!config.userId && instantEncryption) {
10
+ throw new Error(
11
+ "[rei-standard-amsg-client] userId is required (omit only when instantEncryption: false)"
12
+ );
13
+ }
9
14
  this._baseUrl = config.baseUrl.replace(/\/+$/, "");
10
- this._userId = config.userId;
15
+ this._customBaseUrls = {};
16
+ if (config.customBaseUrls && typeof config.customBaseUrls === "object") {
17
+ for (const [name, url] of Object.entries(config.customBaseUrls)) {
18
+ if (typeof url === "string" && url) {
19
+ this._customBaseUrls[name] = url.replace(/\/+$/, "");
20
+ }
21
+ }
22
+ }
23
+ this._userId = config.userId || "";
11
24
  this._userKey = null;
25
+ this._instantEncryption = instantEncryption;
26
+ this._instantClientToken = typeof config.instantClientToken === "string" && config.instantClientToken ? config.instantClientToken : "";
27
+ }
28
+ /**
29
+ * Resolve the base URL for a given endpoint, falling back to `baseUrl`.
30
+ *
31
+ * @private
32
+ * @param {string} endpointName
33
+ * @returns {string}
34
+ */
35
+ _resolveBaseUrl(endpointName) {
36
+ return this._customBaseUrls[endpointName] || this._baseUrl;
12
37
  }
13
38
  // ─── Initialisation ─────────────────────────────────────────────
14
39
  /**
15
40
  * Fetch the user-specific encryption key.
16
41
  * Must be called before any encrypted request.
42
+ *
43
+ * In plaintext-instant mode (`instantEncryption: false`) this is a no-op:
44
+ * `sendInstant()` does not need a userKey. Note that if you also intend to
45
+ * call `scheduleMessage` / `listMessages` / `updateMessage` (which always
46
+ * use AES-256-GCM), you must construct with `instantEncryption: true`
47
+ * (the default) — those methods will throw "Not initialised" otherwise.
17
48
  */
18
49
  async init() {
50
+ if (this._instantEncryption === false) {
51
+ return;
52
+ }
19
53
  const res = await fetch(`${this._baseUrl}/get-user-key`, {
20
54
  method: "GET",
21
55
  headers: { "X-User-Id": this._userId }
@@ -30,7 +64,14 @@ var ReiClient = class {
30
64
  }
31
65
  // ─── Public API ─────────────────────────────────────────────────
32
66
  /**
33
- * Schedule (or instantly send) a message.
67
+ * Schedule a message.
68
+ *
69
+ * Note: For `messageType: 'instant'`, prefer `sendInstant()` instead.
70
+ * That routes through `@rei-standard/amsg-instant` (stateless, no DB
71
+ * round-trip) rather than `amsg-server`'s schedule-message endpoint.
72
+ * This method still works for instant via amsg-server for backward
73
+ * compatibility — see CHANGELOG / README for details.
74
+ *
34
75
  * The payload is automatically encrypted before transmission.
35
76
  *
36
77
  * @param {Object} payload - Schedule message payload.
@@ -50,6 +91,58 @@ var ReiClient = class {
50
91
  });
51
92
  return res.json();
52
93
  }
94
+ /**
95
+ * Send a one-shot instant message via `@rei-standard/amsg-instant`.
96
+ *
97
+ * Compared to `scheduleMessage({ messageType: 'instant', ... })`:
98
+ * - No DB round-trip on the server side (stateless)
99
+ * - Deployable to Cloudflare Workers / Deno Deploy / Vercel Edge
100
+ * - Rejects scheduled-only fields (`firstSendTime`, `recurrenceType`)
101
+ *
102
+ * Two transport modes (chosen by constructor `instantEncryption`):
103
+ *
104
+ * - **Encrypted (default)** — payload is AES-256-GCM encrypted with the
105
+ * `userKey` fetched by `init()`. Compatible with amsg-instant 0.1.x and
106
+ * with amsg-server's `schedule-message` instant path. Sends
107
+ * `X-User-Id` + `X-Payload-Encrypted: true` + `X-Encryption-Version: 1`.
108
+ *
109
+ * - **Plaintext** (`instantEncryption: false`) — payload is sent as raw
110
+ * JSON. Targets amsg-instant 0.2.x. Sends `X-Client-Token` if
111
+ * `instantClientToken` was configured.
112
+ *
113
+ * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`.
114
+ *
115
+ * @param {Object} payload - Instant message payload.
116
+ * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'.
117
+ * @param {{ authorization?: string }} [opts] - Optional auth header to forward.
118
+ * @returns {Promise<Object>} `{ success, data?: { messagesSent, sentAt }, error? }`
119
+ */
120
+ async sendInstant(payload, endpointPath = "/instant", opts = {}) {
121
+ const headers = { "Content-Type": "application/json" };
122
+ let body;
123
+ if (this._instantEncryption === false) {
124
+ body = JSON.stringify(payload);
125
+ if (this._instantClientToken) {
126
+ headers["X-Client-Token"] = this._instantClientToken;
127
+ }
128
+ } else {
129
+ const encrypted = await this._encrypt(JSON.stringify(payload));
130
+ headers["X-User-Id"] = this._userId;
131
+ headers["X-Payload-Encrypted"] = "true";
132
+ headers["X-Encryption-Version"] = "1";
133
+ body = JSON.stringify(encrypted);
134
+ }
135
+ if (opts.authorization) {
136
+ headers["Authorization"] = opts.authorization;
137
+ }
138
+ const path = endpointPath.startsWith("/") ? endpointPath : `/${endpointPath}`;
139
+ const res = await fetch(`${this._resolveBaseUrl("instant")}${path}`, {
140
+ method: "POST",
141
+ headers,
142
+ body
143
+ });
144
+ return res.json();
145
+ }
53
146
  /**
54
147
  * Update an existing scheduled message.
55
148
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-client",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "ReiStandard Active Messaging browser client SDK",
5
5
  "repository": {
6
6
  "type": "git",