@relaymessenger/openclaw-plugin 0.3.0 → 0.3.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.
@@ -67,6 +67,80 @@ function lockRetryDelayMs(attempt, remainingMs) {
67
67
  const backoff = Math.min(25 * 2 ** attempt, 250);
68
68
  return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
69
69
  }
70
+ /**
71
+ * The sidecar lock has no in-process fast path: every waiter polls the lock
72
+ * file, and losing an attempt costs an exclusive create plus a snapshot read.
73
+ * Relay mutates one document from several tasks at once — a poll batch
74
+ * registers one dedupe entry per inbound message — so N in-process writers
75
+ * become N pollers competing with the holder for the same file. Funnelling
76
+ * them through one in-memory queue leaves a single poller per process, which
77
+ * matters most on Windows: same-process losers no longer race the holder's
78
+ * unlink, so contention stops manifesting as delete-pending denials.
79
+ *
80
+ * Keyed by the store's file path. Two paths spelled differently for one file
81
+ * would each get a queue and simply fall back to the sidecar lock for
82
+ * correctness, so a miss costs throughput rather than serialization.
83
+ */
84
+ const RELAY_STATE_MUTEX_KEY = Symbol.for("relay.stateFileMutexes");
85
+ function stateFileMutexes() {
86
+ const container = globalThis;
87
+ container[RELAY_STATE_MUTEX_KEY] ??= new Map();
88
+ return container[RELAY_STATE_MUTEX_KEY];
89
+ }
90
+ function fileLockTimeout(filePath) {
91
+ return Object.assign(new Error(`file lock timeout for ${filePath}`), {
92
+ code: "file_lock_timeout",
93
+ });
94
+ }
95
+ /** Waits for our turn, but never past the caller's lock deadline. */
96
+ async function awaitTurn(turn, deadline, filePath) {
97
+ const remaining = deadline - Date.now();
98
+ if (remaining <= 0)
99
+ throw fileLockTimeout(filePath);
100
+ let timer;
101
+ try {
102
+ await Promise.race([
103
+ turn,
104
+ new Promise((_resolve, reject) => {
105
+ timer = setTimeout(() => reject(fileLockTimeout(filePath)), remaining);
106
+ }),
107
+ ]);
108
+ }
109
+ finally {
110
+ if (timer)
111
+ clearTimeout(timer);
112
+ }
113
+ }
114
+ async function withStateFileMutex(filePath, deadline, run) {
115
+ const mutexes = stateFileMutexes();
116
+ const previous = mutexes.get(filePath);
117
+ let release;
118
+ const ours = new Promise((resolve) => {
119
+ release = resolve;
120
+ });
121
+ // Chain even when we abandon our turn on timeout: later waiters still queue
122
+ // behind the holder we were waiting on, so ordering survives a giving-up
123
+ // waiter.
124
+ const tail = previous ? previous.then(() => ours) : ours;
125
+ mutexes.set(filePath, tail);
126
+ let tookTurn = false;
127
+ try {
128
+ if (previous)
129
+ await awaitTurn(previous, deadline, filePath);
130
+ tookTurn = true;
131
+ return await run();
132
+ }
133
+ finally {
134
+ release();
135
+ // Forgetting the queue is only safe once it has drained. A waiter that gave
136
+ // up is still queued behind a holder that is running, so dropping the entry
137
+ // there would let the next caller past the holder and back onto the lock
138
+ // file the queue exists to keep it off. Leaving it costs one settled promise
139
+ // until the next caller drains it.
140
+ if (tookTurn && mutexes.get(filePath) === tail)
141
+ mutexes.delete(filePath);
142
+ }
143
+ }
70
144
  function canRecoverRelayStateLock(value) {
71
145
  return (isRelayStateLockOwner(value) &&
72
146
  value.host === hostname() &&
@@ -112,46 +186,48 @@ export function openRelayStateDocument(params) {
112
186
  });
113
187
  const withMutationLock = async (run) => {
114
188
  const deadline = Date.now() + lockTimeoutMs;
115
- for (let attempt = 0;; attempt += 1) {
116
- // Only acquisition is retried. Once the mutation itself has started it has
117
- // observed state under the lock, so replaying it could double-apply.
118
- let mutationStarted = false;
119
- try {
120
- return await withFileLock(store.filePath, {
121
- managerKey: `relay-state:${store.filePath}`,
122
- staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
123
- timeoutMs: Math.max(1, deadline - Date.now()),
124
- staleRecovery: "remove-if-unchanged",
125
- retry: {
126
- retries: 300,
127
- minTimeout: 25,
128
- maxTimeout: 250,
129
- randomize: true,
130
- },
131
- payload: () => ({
132
- version: RELAY_STATE_LOCK_VERSION,
133
- kind: "relay-state",
134
- pid: process.pid,
135
- host: hostname(),
136
- createdAt: new Date().toISOString(),
137
- }),
138
- shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
139
- shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
140
- }, async () => {
141
- mutationStarted = true;
142
- return await run();
143
- });
144
- }
145
- catch (error) {
146
- const remaining = deadline - Date.now();
147
- if (mutationStarted ||
148
- remaining <= 0 ||
149
- !isWindowsLockAcquisitionContention(error)) {
150
- throw error;
189
+ return await withStateFileMutex(store.filePath, deadline, async () => {
190
+ for (let attempt = 0;; attempt += 1) {
191
+ // Only acquisition is retried. Once the mutation itself has started it
192
+ // has observed state under the lock, so replaying it could double-apply.
193
+ let mutationStarted = false;
194
+ try {
195
+ return await withFileLock(store.filePath, {
196
+ managerKey: `relay-state:${store.filePath}`,
197
+ staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
198
+ timeoutMs: Math.max(1, deadline - Date.now()),
199
+ staleRecovery: "remove-if-unchanged",
200
+ retry: {
201
+ retries: 300,
202
+ minTimeout: 25,
203
+ maxTimeout: 250,
204
+ randomize: true,
205
+ },
206
+ payload: () => ({
207
+ version: RELAY_STATE_LOCK_VERSION,
208
+ kind: "relay-state",
209
+ pid: process.pid,
210
+ host: hostname(),
211
+ createdAt: new Date().toISOString(),
212
+ }),
213
+ shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
214
+ shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
215
+ }, async () => {
216
+ mutationStarted = true;
217
+ return await run();
218
+ });
219
+ }
220
+ catch (error) {
221
+ const remaining = deadline - Date.now();
222
+ if (mutationStarted ||
223
+ remaining <= 0 ||
224
+ !isWindowsLockAcquisitionContention(error)) {
225
+ throw error;
226
+ }
227
+ await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs(attempt, remaining)));
151
228
  }
152
- await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs(attempt, remaining)));
153
229
  }
154
- }
230
+ });
155
231
  };
156
232
  return {
157
233
  filePath: store.filePath,
@@ -0,0 +1,163 @@
1
+ import { RelayApiError, classifyRelayHttpStatus, isAbortError, } from "./errors.js";
2
+ import { normalizeRelayBaseUrl } from "./url.js";
3
+ async function readErrorDetail(response) {
4
+ try {
5
+ const body = (await response.json());
6
+ return {
7
+ ...(body?.error?.code ? { code: body.error.code } : {}),
8
+ ...(body?.error?.details ? { details: body.error.details } : {}),
9
+ message: body?.error?.message ?? body?.message ?? "",
10
+ };
11
+ }
12
+ catch {
13
+ return { message: "" };
14
+ }
15
+ }
16
+ export function createRelayClient(options) {
17
+ if (!options.token.trim()) {
18
+ throw new Error("relay: Agent Token is required");
19
+ }
20
+ const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
21
+ const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
22
+ const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
23
+ const request = async (params) => {
24
+ const url = new URL(`${baseUrl}${params.path}`);
25
+ for (const [key, value] of Object.entries(params.query ?? {})) {
26
+ if (value !== undefined)
27
+ url.searchParams.set(key, String(value));
28
+ }
29
+ const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
30
+ const signal = params.signal
31
+ ? AbortSignal.any([params.signal, timeoutSignal])
32
+ : timeoutSignal;
33
+ let response;
34
+ try {
35
+ response = await fetchImpl(url.toString(), {
36
+ method: params.method,
37
+ headers: {
38
+ authorization: `Bearer ${options.token}`,
39
+ ...(params.body === undefined ? {} : { "content-type": "application/json" }),
40
+ ...params.headers,
41
+ },
42
+ ...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
43
+ signal,
44
+ });
45
+ }
46
+ catch (error) {
47
+ if (timeoutSignal.aborted && !params.signal?.aborted) {
48
+ throw new RelayApiError(`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`, { kind: "retryable" });
49
+ }
50
+ if (isAbortError(error))
51
+ throw error;
52
+ throw new RelayApiError(`relay: network error: ${String(error)}`, {
53
+ kind: "retryable",
54
+ });
55
+ }
56
+ if (!response.ok) {
57
+ const detail = await readErrorDetail(response);
58
+ throw new RelayApiError(`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`, {
59
+ status: response.status,
60
+ kind: classifyRelayHttpStatus(response.status),
61
+ ...(detail.code ? { code: detail.code } : {}),
62
+ ...(detail.details ? { details: detail.details } : {}),
63
+ });
64
+ }
65
+ return response;
66
+ };
67
+ const client = {
68
+ baseUrl,
69
+ getMe: async (params) => {
70
+ const response = await request({
71
+ method: "GET",
72
+ path: "/v1/agents/me",
73
+ ...(params?.signal ? { signal: params.signal } : {}),
74
+ });
75
+ const body = (await response.json());
76
+ return body.agent;
77
+ },
78
+ pollEvents: async (params) => {
79
+ const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
80
+ const response = await request({
81
+ method: "GET",
82
+ path: "/v1/events",
83
+ query: {
84
+ cursor: params.cursor,
85
+ timeout: timeoutSeconds,
86
+ ...(params.limit === undefined ? {} : { limit: params.limit }),
87
+ },
88
+ ...(params.signal ? { signal: params.signal } : {}),
89
+ timeoutMs: (timeoutSeconds + 15) * 1_000,
90
+ });
91
+ const body = (await response.json());
92
+ const events = Array.isArray(body.events) ? body.events : [];
93
+ const nextCursor = typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
94
+ ? body.next_cursor
95
+ : params.cursor;
96
+ return { events, nextCursor };
97
+ },
98
+ sendMessage: async (params) => {
99
+ const response = await request({
100
+ method: "POST",
101
+ path: "/v1/messages",
102
+ headers: { "idempotency-key": params.idempotencyKey },
103
+ body: {
104
+ conversation_id: params.conversationId,
105
+ parts: params.parts,
106
+ ...(params.invocationId ? { invocation_id: params.invocationId } : {}),
107
+ ...(params.replyTo ? { reply_to: params.replyTo } : {}),
108
+ },
109
+ ...(params.signal ? { signal: params.signal } : {}),
110
+ });
111
+ const body = (await response.json());
112
+ return { messages: body.messages };
113
+ },
114
+ sendText: async (params) => {
115
+ const { text, ...rest } = params;
116
+ return client.sendMessage({
117
+ ...rest,
118
+ parts: [{ type: "text", text }],
119
+ });
120
+ },
121
+ setTyping: async (params) => {
122
+ await request({
123
+ method: "POST",
124
+ path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
125
+ body: {
126
+ started: params.started,
127
+ ...(params.label ? { label: params.label } : {}),
128
+ ...(params.invocationId ? { invocation_id: params.invocationId } : {}),
129
+ },
130
+ ...(params.signal ? { signal: params.signal } : {}),
131
+ });
132
+ },
133
+ setResponding: async (params) => {
134
+ await request({
135
+ method: "POST",
136
+ path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
137
+ body: {
138
+ message_id: params.messageId,
139
+ ...(params.label ? { label: params.label } : {}),
140
+ ...(params.invocationId ? { invocation_id: params.invocationId } : {}),
141
+ },
142
+ ...(params.signal ? { signal: params.signal } : {}),
143
+ });
144
+ },
145
+ markDelivered: async (params) => {
146
+ await request({
147
+ method: "POST",
148
+ path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/delivered`,
149
+ body: { message_id: params.messageId },
150
+ ...(params.signal ? { signal: params.signal } : {}),
151
+ });
152
+ },
153
+ markRead: async (params) => {
154
+ await request({
155
+ method: "POST",
156
+ path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
157
+ body: { message_id: params.messageId },
158
+ ...(params.signal ? { signal: params.signal } : {}),
159
+ });
160
+ },
161
+ };
162
+ return client;
163
+ }
@@ -0,0 +1,45 @@
1
+ /** Classified Relay API failure. `terminal` means retrying the same request cannot succeed. */
2
+ export class RelayApiError extends Error {
3
+ status;
4
+ kind;
5
+ code;
6
+ /** Structured `error.details` from the response body, e.g. `highest_delivered_cursor` on 422. */
7
+ details;
8
+ constructor(message, params) {
9
+ super(message);
10
+ this.name = "RelayApiError";
11
+ this.status = params.status;
12
+ this.kind = params.kind;
13
+ this.code = params.code;
14
+ this.details = params.details;
15
+ }
16
+ get terminal() {
17
+ return this.kind !== "retryable";
18
+ }
19
+ get retryable() {
20
+ return this.kind === "retryable";
21
+ }
22
+ }
23
+ export class WebhookVerificationError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = "WebhookVerificationError";
27
+ }
28
+ }
29
+ export function classifyRelayHttpStatus(status) {
30
+ if (status === 401)
31
+ return "auth";
32
+ if (status === 409)
33
+ return "conflict";
34
+ if (status === 408 || status === 429 || status >= 500)
35
+ return "retryable";
36
+ return "rejected";
37
+ }
38
+ export function isAbortError(error) {
39
+ return error instanceof Error && error.name === "AbortError";
40
+ }
41
+ export function isRelayWebhookConflict(error) {
42
+ return (error instanceof RelayApiError &&
43
+ error.status === 409 &&
44
+ error.code !== "terminated_by_other_consumer");
45
+ }
@@ -0,0 +1,2 @@
1
+ /** Wire types for the Relay v1 developer API used by plugins and examples. */
2
+ export {};
@@ -0,0 +1,39 @@
1
+ import { isIP } from "node:net";
2
+ export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
3
+ function isLoopbackHostname(hostname) {
4
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
5
+ const ipVersion = isIP(normalized);
6
+ if (ipVersion === 4)
7
+ return normalized.split(".")[0] === "127";
8
+ if (ipVersion === 6)
9
+ return normalized === "::1";
10
+ return normalized === "localhost" || normalized.endsWith(".localhost");
11
+ }
12
+ /**
13
+ * Validate and canonicalize the API origin before a bearer token can be sent.
14
+ * Remote origins must use HTTPS. Plain HTTP is allowed only on loopback.
15
+ */
16
+ export function normalizeRelayBaseUrl(raw) {
17
+ const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
18
+ let url;
19
+ try {
20
+ url = new URL(candidate);
21
+ }
22
+ catch {
23
+ throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
24
+ }
25
+ if (url.username || url.password) {
26
+ throw new Error("relay: baseUrl must not contain credentials");
27
+ }
28
+ if (url.search || url.hash) {
29
+ throw new Error("relay: baseUrl must not contain a query or fragment");
30
+ }
31
+ if (!/^\/+$/u.test(url.pathname)) {
32
+ throw new Error("relay: baseUrl must be an origin without a path");
33
+ }
34
+ if (url.protocol !== "https:" &&
35
+ !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
36
+ throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
37
+ }
38
+ return url.origin;
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relaymessenger/openclaw-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.3.4",
4
4
  "description": "Relay channel plugin for OpenClaw. Text your OpenClaw like a friend.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {