@agent-relay/dashboard 2.0.91 → 2.0.93
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/out/404.html +1 -1
- package/out/_next/static/chunks/{5518-6d77237eefc8d5ae.js → 5518-3b96a248632a79c0.js} +1 -1
- package/out/about.html +1 -1
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +1 -1
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
- package/out/blog.html +1 -1
- package/out/blog.txt +1 -1
- package/out/careers.html +1 -1
- package/out/careers.txt +1 -1
- package/out/changelog.html +1 -1
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +1 -1
- package/out/complete-profile.html +1 -1
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +1 -1
- package/out/contact.txt +1 -1
- package/out/dev/cli-tools.html +1 -1
- package/out/dev/cli-tools.txt +1 -1
- package/out/dev/log-viewer.html +1 -1
- package/out/dev/log-viewer.txt +1 -1
- package/out/docs.html +1 -1
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +1 -1
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +1 -1
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +1 -1
- package/out/pricing.html +1 -1
- package/out/pricing.txt +1 -1
- package/out/privacy.html +1 -1
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +1 -1
- package/out/security.txt +1 -1
- package/out/signup.html +1 -1
- package/out/signup.txt +1 -1
- package/out/terms.html +1 -1
- package/out/terms.txt +1 -1
- package/package.json +1 -1
- package/src/components/hooks/useOrchestrator.test.ts +3 -3
- package/src/providers/RelayConfigProvider.integration.test.tsx +549 -0
- package/src/providers/RelayConfigProvider.test.tsx +299 -0
- package/src/providers/RelayConfigProvider.tsx +198 -16
- /package/out/_next/static/{2GceRdfHFOB2Vub1wjdqt → 9CykW6n4dJn75_XoFVN_X}/_buildManifest.js +0 -0
- /package/out/_next/static/{2GceRdfHFOB2Vub1wjdqt → 9CykW6n4dJn75_XoFVN_X}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
type MockRelayProviderProps = {
|
|
10
|
+
apiKey: string;
|
|
11
|
+
agentToken: string;
|
|
12
|
+
wsToken?: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
channels?: string[];
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const relayProviderCalls: MockRelayProviderProps[] = [];
|
|
19
|
+
|
|
20
|
+
vi.mock('@relaycast/react', () => ({
|
|
21
|
+
RelayProvider: ({ children, ...props }: MockRelayProviderProps) => {
|
|
22
|
+
relayProviderCalls.push(props);
|
|
23
|
+
return <>{children}</>;
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { RelayConfigProvider } from './RelayConfigProvider';
|
|
28
|
+
|
|
29
|
+
interface RelayConfigPayload {
|
|
30
|
+
success: boolean;
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
apiKey: string;
|
|
33
|
+
agentToken: string;
|
|
34
|
+
agentName: string;
|
|
35
|
+
channels: string[];
|
|
36
|
+
wsToken?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface QueuedFetchResponse {
|
|
40
|
+
url: string;
|
|
41
|
+
status?: number;
|
|
42
|
+
payload?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makeConfig(agentToken: string, overrides: Partial<RelayConfigPayload> = {}): RelayConfigPayload {
|
|
46
|
+
return {
|
|
47
|
+
success: true,
|
|
48
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
49
|
+
apiKey: 'rk_test',
|
|
50
|
+
agentToken,
|
|
51
|
+
agentName: 'relay-dashboard',
|
|
52
|
+
channels: ['general'],
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function flushPromises(): Promise<void> {
|
|
58
|
+
await act(async () => {
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class MockBroadcastChannel {
|
|
65
|
+
static channels = new Map<string, Set<MockBroadcastChannel>>();
|
|
66
|
+
|
|
67
|
+
name: string;
|
|
68
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
69
|
+
|
|
70
|
+
constructor(name: string) {
|
|
71
|
+
this.name = name;
|
|
72
|
+
const peers = MockBroadcastChannel.channels.get(name) ?? new Set<MockBroadcastChannel>();
|
|
73
|
+
peers.add(this);
|
|
74
|
+
MockBroadcastChannel.channels.set(name, peers);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
postMessage(data: unknown): void {
|
|
78
|
+
const peers = MockBroadcastChannel.channels.get(this.name);
|
|
79
|
+
if (!peers) return;
|
|
80
|
+
|
|
81
|
+
for (const peer of peers) {
|
|
82
|
+
if (peer === this) continue;
|
|
83
|
+
peer.onmessage?.({ data } as MessageEvent);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close(): void {
|
|
88
|
+
const peers = MockBroadcastChannel.channels.get(this.name);
|
|
89
|
+
peers?.delete(this);
|
|
90
|
+
if (peers && peers.size === 0) {
|
|
91
|
+
MockBroadcastChannel.channels.delete(this.name);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('RelayConfigProvider', () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
relayProviderCalls.length = 0;
|
|
99
|
+
MockBroadcastChannel.channels.clear();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
cleanup();
|
|
104
|
+
vi.unstubAllGlobals();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('passes the stable wsToken to RelayProvider', async () => {
|
|
108
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => ({
|
|
109
|
+
status: 200,
|
|
110
|
+
ok: true,
|
|
111
|
+
json: async () => {
|
|
112
|
+
expect(String(input)).toBe('/api/relay-config');
|
|
113
|
+
return makeConfig('agt_old', { wsToken: 'rk_ws' });
|
|
114
|
+
},
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
118
|
+
|
|
119
|
+
render(
|
|
120
|
+
<RelayConfigProvider>
|
|
121
|
+
<div>dashboard</div>
|
|
122
|
+
</RelayConfigProvider>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await flushPromises();
|
|
126
|
+
|
|
127
|
+
expect(relayProviderCalls.at(-1)).toMatchObject({
|
|
128
|
+
apiKey: 'rk_test',
|
|
129
|
+
agentToken: 'agt_old',
|
|
130
|
+
wsToken: 'rk_ws',
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('refreshes a stale agent token on Relaycast 401 and retries once with the new token', async () => {
|
|
135
|
+
const queuedResponses: QueuedFetchResponse[] = [
|
|
136
|
+
{
|
|
137
|
+
url: '/api/relay-config',
|
|
138
|
+
payload: makeConfig('agt_old', { wsToken: 'rk_test' }),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
url: 'https://api.relaycast.dev/v1/channels',
|
|
142
|
+
status: 401,
|
|
143
|
+
payload: { ok: false, error: { code: 'unauthorized', message: 'stale token' } },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
url: '/api/relay-config?refresh=true',
|
|
147
|
+
payload: makeConfig('agt_new', { wsToken: 'rk_test' }),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
url: 'https://api.relaycast.dev/v1/channels',
|
|
151
|
+
payload: { ok: true, data: [] },
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
156
|
+
const next = queuedResponses.shift();
|
|
157
|
+
if (!next) {
|
|
158
|
+
throw new Error(`Unexpected fetch: ${String(input)}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
expect(String(input)).toBe(next.url);
|
|
162
|
+
if (next.url === 'https://api.relaycast.dev/v1/channels') {
|
|
163
|
+
const headers = new Headers(init?.headers);
|
|
164
|
+
const authHeader = headers.get('Authorization');
|
|
165
|
+
if (next.status === 401) {
|
|
166
|
+
expect(authHeader).toBe('Bearer agt_old');
|
|
167
|
+
} else {
|
|
168
|
+
expect(authHeader).toBe('Bearer agt_new');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const status = next.status ?? 200;
|
|
173
|
+
return {
|
|
174
|
+
status,
|
|
175
|
+
ok: status >= 200 && status < 300,
|
|
176
|
+
json: async () => next.payload,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
181
|
+
|
|
182
|
+
render(
|
|
183
|
+
<RelayConfigProvider>
|
|
184
|
+
<div>dashboard</div>
|
|
185
|
+
</RelayConfigProvider>,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await flushPromises();
|
|
189
|
+
|
|
190
|
+
let response: Response;
|
|
191
|
+
await act(async () => {
|
|
192
|
+
response = await globalThis.fetch('https://api.relaycast.dev/v1/channels', {
|
|
193
|
+
headers: {
|
|
194
|
+
Authorization: 'Bearer agt_old',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
await Promise.resolve();
|
|
198
|
+
});
|
|
199
|
+
await flushPromises();
|
|
200
|
+
|
|
201
|
+
expect(response!.status).toBe(200);
|
|
202
|
+
expect(relayProviderCalls.at(-1)).toMatchObject({
|
|
203
|
+
agentToken: 'agt_new',
|
|
204
|
+
wsToken: 'rk_test',
|
|
205
|
+
});
|
|
206
|
+
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
|
207
|
+
'/api/relay-config',
|
|
208
|
+
'https://api.relaycast.dev/v1/channels',
|
|
209
|
+
'/api/relay-config?refresh=true',
|
|
210
|
+
'https://api.relaycast.dev/v1/channels',
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('retries with the current broadcasted token before forcing another refresh', async () => {
|
|
215
|
+
const queuedResponses: QueuedFetchResponse[] = [
|
|
216
|
+
{
|
|
217
|
+
url: '/api/relay-config',
|
|
218
|
+
payload: makeConfig('agt_old', { wsToken: 'rk_test' }),
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
url: 'https://api.relaycast.dev/v1/channels',
|
|
222
|
+
status: 401,
|
|
223
|
+
payload: { ok: false, error: { code: 'unauthorized', message: 'stale token' } },
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
url: 'https://api.relaycast.dev/v1/channels',
|
|
227
|
+
payload: { ok: true, data: [] },
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
232
|
+
const next = queuedResponses.shift();
|
|
233
|
+
if (!next) {
|
|
234
|
+
throw new Error(`Unexpected fetch: ${String(input)}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
expect(String(input)).toBe(next.url);
|
|
238
|
+
if (next.url === 'https://api.relaycast.dev/v1/channels') {
|
|
239
|
+
const headers = new Headers(init?.headers);
|
|
240
|
+
const authHeader = headers.get('Authorization');
|
|
241
|
+
if (next.status === 401) {
|
|
242
|
+
expect(authHeader).toBe('Bearer agt_old');
|
|
243
|
+
} else {
|
|
244
|
+
expect(authHeader).toBe('Bearer agt_new');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const status = next.status ?? 200;
|
|
249
|
+
return {
|
|
250
|
+
status,
|
|
251
|
+
ok: status >= 200 && status < 300,
|
|
252
|
+
json: async () => next.payload,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
257
|
+
vi.stubGlobal('BroadcastChannel', MockBroadcastChannel);
|
|
258
|
+
|
|
259
|
+
render(
|
|
260
|
+
<RelayConfigProvider>
|
|
261
|
+
<div>dashboard</div>
|
|
262
|
+
</RelayConfigProvider>,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
await flushPromises();
|
|
266
|
+
|
|
267
|
+
const peer = new MockBroadcastChannel('relay-dashboard:relay-config');
|
|
268
|
+
await act(async () => {
|
|
269
|
+
peer.postMessage({
|
|
270
|
+
type: 'relay-config-refreshed',
|
|
271
|
+
config: makeConfig('agt_new', { wsToken: 'rk_test' }),
|
|
272
|
+
});
|
|
273
|
+
await Promise.resolve();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
let response: Response;
|
|
277
|
+
await act(async () => {
|
|
278
|
+
response = await globalThis.fetch('https://api.relaycast.dev/v1/channels', {
|
|
279
|
+
headers: {
|
|
280
|
+
Authorization: 'Bearer agt_old',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
await Promise.resolve();
|
|
284
|
+
});
|
|
285
|
+
await flushPromises();
|
|
286
|
+
|
|
287
|
+
expect(response!.status).toBe(200);
|
|
288
|
+
expect(relayProviderCalls.at(-1)).toMatchObject({
|
|
289
|
+
agentToken: 'agt_new',
|
|
290
|
+
wsToken: 'rk_test',
|
|
291
|
+
});
|
|
292
|
+
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
|
|
293
|
+
'/api/relay-config',
|
|
294
|
+
'https://api.relaycast.dev/v1/channels',
|
|
295
|
+
'https://api.relaycast.dev/v1/channels',
|
|
296
|
+
]);
|
|
297
|
+
peer.close();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { RelayProvider } from '@relaycast/react';
|
|
5
5
|
|
|
6
6
|
interface RelayConfigResponse {
|
|
@@ -10,6 +10,8 @@ interface RelayConfigResponse {
|
|
|
10
10
|
agentToken?: string;
|
|
11
11
|
agentName?: string | null;
|
|
12
12
|
channels?: string[];
|
|
13
|
+
/** Workspace API key for WebSocket auth (stable across agent token rotation). */
|
|
14
|
+
wsToken?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface RelayConfigProviderProps {
|
|
@@ -34,37 +36,214 @@ export function useRelayConfigStatus(): RelayConfigStatus {
|
|
|
34
36
|
|
|
35
37
|
/** Default channels the dashboard agent should subscribe to via WebSocket */
|
|
36
38
|
const DEFAULT_CHANNELS = ['general'];
|
|
39
|
+
const RELAY_CONFIG_CHANNEL = 'relay-dashboard:relay-config';
|
|
40
|
+
|
|
41
|
+
interface RelayConfigBroadcastMessage {
|
|
42
|
+
type: 'relay-config-refreshed';
|
|
43
|
+
config: RelayConfigResponse;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function fetchRelayConfig(refresh = false): Promise<RelayConfigResponse | null> {
|
|
47
|
+
const url = refresh ? '/api/relay-config?refresh=true' : '/api/relay-config';
|
|
48
|
+
const response = await fetch(url, { credentials: 'include' });
|
|
49
|
+
if (!response.ok) return null;
|
|
50
|
+
const payload = await response.json() as RelayConfigResponse;
|
|
51
|
+
if (!hasUsableRelayConfig(payload)) return null;
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasUsableRelayConfig(payload: RelayConfigResponse | null | undefined): payload is RelayConfigResponse {
|
|
56
|
+
return Boolean(payload?.success && payload.baseUrl && payload.apiKey && payload.agentToken);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sameRelayConfig(current: RelayConfigResponse | null, next: RelayConfigResponse): boolean {
|
|
60
|
+
if (!current) return false;
|
|
61
|
+
|
|
62
|
+
const currentChannels = current.channels ?? [];
|
|
63
|
+
const nextChannels = next.channels ?? [];
|
|
64
|
+
if (currentChannels.length !== nextChannels.length) return false;
|
|
65
|
+
if (currentChannels.some((channel, index) => channel !== nextChannels[index])) return false;
|
|
66
|
+
|
|
67
|
+
return current.baseUrl === next.baseUrl
|
|
68
|
+
&& current.apiKey === next.apiKey
|
|
69
|
+
&& current.agentToken === next.agentToken
|
|
70
|
+
&& current.wsToken === next.wsToken
|
|
71
|
+
&& current.agentName === next.agentName;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isRelaycastUrl(url: string, baseUrl: string): boolean {
|
|
75
|
+
return url.startsWith(`${baseUrl.replace(/\/+$/, '')}/`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractRequestUrl(input: RequestInfo | URL): string {
|
|
79
|
+
if (typeof input === 'string') return input;
|
|
80
|
+
if (input instanceof URL) return input.toString();
|
|
81
|
+
return input.url;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {
|
|
85
|
+
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
|
86
|
+
if (init?.headers) {
|
|
87
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
88
|
+
headers.set(key, value);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getBearerToken(headers: Headers): string | null {
|
|
95
|
+
const authorization = headers.get('Authorization');
|
|
96
|
+
if (!authorization?.startsWith('Bearer ')) return null;
|
|
97
|
+
return authorization.slice(7);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function withBearerToken(init: RequestInit | undefined, token: string): RequestInit {
|
|
101
|
+
const headers = new Headers(init?.headers);
|
|
102
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
103
|
+
return {
|
|
104
|
+
...init,
|
|
105
|
+
headers,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
37
108
|
|
|
38
109
|
export function RelayConfigProvider({ children }: RelayConfigProviderProps) {
|
|
39
110
|
const [config, setConfig] = useState<RelayConfigResponse | null>(null);
|
|
40
111
|
const [loaded, setLoaded] = useState(false);
|
|
112
|
+
const broadcastChannelRef = useRef<BroadcastChannel | null>(null);
|
|
113
|
+
const configRef = useRef<RelayConfigResponse | null>(null);
|
|
114
|
+
const refreshPromiseRef = useRef<Promise<RelayConfigResponse | null> | null>(null);
|
|
115
|
+
|
|
116
|
+
const applyRelayConfig = useCallback((nextConfig: RelayConfigResponse, options?: { broadcast?: boolean }) => {
|
|
117
|
+
if (!hasUsableRelayConfig(nextConfig)) return;
|
|
118
|
+
|
|
119
|
+
configRef.current = nextConfig;
|
|
120
|
+
setConfig((current) => (sameRelayConfig(current, nextConfig) ? current : nextConfig));
|
|
121
|
+
setLoaded(true);
|
|
122
|
+
|
|
123
|
+
if (options?.broadcast) {
|
|
124
|
+
try {
|
|
125
|
+
const message: RelayConfigBroadcastMessage = {
|
|
126
|
+
type: 'relay-config-refreshed',
|
|
127
|
+
config: nextConfig,
|
|
128
|
+
};
|
|
129
|
+
broadcastChannelRef.current?.postMessage(message);
|
|
130
|
+
} catch {
|
|
131
|
+
// BroadcastChannel is best-effort only.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
const refreshAgentToken = useCallback(async (): Promise<RelayConfigResponse | null> => {
|
|
137
|
+
if (refreshPromiseRef.current) return refreshPromiseRef.current;
|
|
138
|
+
|
|
139
|
+
const promise = fetchRelayConfig(true)
|
|
140
|
+
.then((payload) => {
|
|
141
|
+
if (payload) {
|
|
142
|
+
applyRelayConfig(payload, { broadcast: true });
|
|
143
|
+
}
|
|
144
|
+
return payload;
|
|
145
|
+
})
|
|
146
|
+
.catch(() => null)
|
|
147
|
+
.finally(() => {
|
|
148
|
+
if (refreshPromiseRef.current === promise) {
|
|
149
|
+
refreshPromiseRef.current = null;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
refreshPromiseRef.current = promise;
|
|
154
|
+
return promise;
|
|
155
|
+
}, [applyRelayConfig]);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (typeof BroadcastChannel !== 'function') return;
|
|
159
|
+
|
|
160
|
+
const channel = new BroadcastChannel(RELAY_CONFIG_CHANNEL);
|
|
161
|
+
broadcastChannelRef.current = channel;
|
|
162
|
+
channel.onmessage = (event: MessageEvent<RelayConfigBroadcastMessage>) => {
|
|
163
|
+
if (event.data?.type !== 'relay-config-refreshed') return;
|
|
164
|
+
applyRelayConfig(event.data.config);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
if (broadcastChannelRef.current === channel) {
|
|
169
|
+
broadcastChannelRef.current = null;
|
|
170
|
+
}
|
|
171
|
+
channel.close();
|
|
172
|
+
};
|
|
173
|
+
}, [applyRelayConfig]);
|
|
41
174
|
|
|
42
175
|
useEffect(() => {
|
|
43
176
|
let cancelled = false;
|
|
44
177
|
|
|
45
|
-
void
|
|
46
|
-
.then(async (response) => {
|
|
47
|
-
if (!response.ok) return null;
|
|
48
|
-
return response.json() as Promise<RelayConfigResponse>;
|
|
49
|
-
})
|
|
178
|
+
void fetchRelayConfig()
|
|
50
179
|
.then((payload) => {
|
|
51
|
-
if (cancelled
|
|
52
|
-
if (
|
|
53
|
-
setConfig(payload);
|
|
54
|
-
})
|
|
55
|
-
.catch(() => {
|
|
56
|
-
// No relay-config is a valid local fallback.
|
|
180
|
+
if (cancelled) return;
|
|
181
|
+
if (payload) applyRelayConfig(payload);
|
|
57
182
|
})
|
|
183
|
+
.catch(() => {})
|
|
58
184
|
.finally(() => {
|
|
59
|
-
if (!cancelled)
|
|
60
|
-
setLoaded(true);
|
|
61
|
-
}
|
|
185
|
+
if (!cancelled) setLoaded(true);
|
|
62
186
|
});
|
|
63
187
|
|
|
64
188
|
return () => {
|
|
65
189
|
cancelled = true;
|
|
66
190
|
};
|
|
67
|
-
}, []);
|
|
191
|
+
}, [applyRelayConfig]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
configRef.current = config;
|
|
195
|
+
}, [config]);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (typeof globalThis.fetch !== 'function') return;
|
|
199
|
+
|
|
200
|
+
const originalFetch = globalThis.fetch.bind(globalThis);
|
|
201
|
+
const interceptedFetch: typeof globalThis.fetch = async (input, init) => {
|
|
202
|
+
const response = await originalFetch(input, init);
|
|
203
|
+
if (response.status !== 401 && response.status !== 403) {
|
|
204
|
+
return response;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const currentConfig = configRef.current;
|
|
208
|
+
if (!hasUsableRelayConfig(currentConfig)) {
|
|
209
|
+
return response;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const url = extractRequestUrl(input);
|
|
213
|
+
if (!isRelaycastUrl(url, currentConfig.baseUrl!)) {
|
|
214
|
+
return response;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const headers = buildHeaders(input, init);
|
|
218
|
+
const requestToken = getBearerToken(headers);
|
|
219
|
+
if (!requestToken || requestToken === currentConfig.apiKey) {
|
|
220
|
+
return response;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const retryWithToken = async (token: string): Promise<Response> => {
|
|
224
|
+
return originalFetch(url, withBearerToken(init, token));
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (requestToken !== currentConfig.agentToken) {
|
|
228
|
+
return retryWithToken(currentConfig.agentToken!);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const refreshed = await refreshAgentToken();
|
|
232
|
+
if (!hasUsableRelayConfig(refreshed) || refreshed.agentToken === requestToken) {
|
|
233
|
+
return response;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return retryWithToken(refreshed.agentToken!);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
globalThis.fetch = interceptedFetch;
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
if (globalThis.fetch === interceptedFetch) {
|
|
243
|
+
globalThis.fetch = originalFetch;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}, [refreshAgentToken]);
|
|
68
247
|
|
|
69
248
|
const configured = Boolean(config?.baseUrl && config.apiKey && config.agentToken);
|
|
70
249
|
const providerConfig = useMemo(() => {
|
|
@@ -73,6 +252,7 @@ export function RelayConfigProvider({ children }: RelayConfigProviderProps) {
|
|
|
73
252
|
baseUrl: config!.baseUrl!,
|
|
74
253
|
apiKey: config!.apiKey!,
|
|
75
254
|
agentToken: config!.agentToken!,
|
|
255
|
+
wsToken: config!.wsToken ?? config!.apiKey!,
|
|
76
256
|
};
|
|
77
257
|
}
|
|
78
258
|
|
|
@@ -80,6 +260,7 @@ export function RelayConfigProvider({ children }: RelayConfigProviderProps) {
|
|
|
80
260
|
baseUrl: typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1',
|
|
81
261
|
apiKey: '__relay_disabled__',
|
|
82
262
|
agentToken: '__relay_disabled__',
|
|
263
|
+
wsToken: '__relay_disabled__',
|
|
83
264
|
};
|
|
84
265
|
}, [configured, config]);
|
|
85
266
|
|
|
@@ -99,6 +280,7 @@ export function RelayConfigProvider({ children }: RelayConfigProviderProps) {
|
|
|
99
280
|
baseUrl={providerConfig.baseUrl}
|
|
100
281
|
apiKey={providerConfig.apiKey}
|
|
101
282
|
agentToken={providerConfig.agentToken}
|
|
283
|
+
wsToken={providerConfig.wsToken}
|
|
102
284
|
channels={channels}
|
|
103
285
|
>
|
|
104
286
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
File without changes
|
|
File without changes
|