@relayfile/file-observer 0.1.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.
@@ -0,0 +1,459 @@
1
+ export const DEFAULT_RELAYFILE_URL = 'https://api.relayfile.dev';
2
+
3
+ export type FileNodeType = 'file' | 'dir';
4
+
5
+ export interface TreeEntry {
6
+ path: string;
7
+ type: FileNodeType;
8
+ revision: string;
9
+ provider?: string;
10
+ providerObjectId?: string;
11
+ size?: number;
12
+ updatedAt?: string;
13
+ propertyCount?: number;
14
+ relationCount?: number;
15
+ permissionCount?: number;
16
+ commentCount?: number;
17
+ }
18
+
19
+ export interface TreeResponse {
20
+ path: string;
21
+ entries: TreeEntry[];
22
+ nextCursor: string | null;
23
+ }
24
+
25
+ export interface FileSemantics {
26
+ properties?: Record<string, string>;
27
+ relations?: string[];
28
+ permissions?: string[];
29
+ comments?: string[];
30
+ }
31
+
32
+ export interface FileReadResponse {
33
+ path: string;
34
+ revision: string;
35
+ contentType: string;
36
+ content: string;
37
+ encoding?: 'utf-8' | 'base64';
38
+ provider?: string;
39
+ providerObjectId?: string;
40
+ lastEditedAt?: string;
41
+ semantics?: FileSemantics;
42
+ }
43
+
44
+ export interface FileQueryItem {
45
+ path: string;
46
+ revision: string;
47
+ contentType: string;
48
+ provider?: string;
49
+ providerObjectId?: string;
50
+ lastEditedAt?: string;
51
+ size: number;
52
+ properties?: Record<string, string>;
53
+ relations?: string[];
54
+ permissions?: string[];
55
+ comments?: string[];
56
+ }
57
+
58
+ export interface FileQueryResponse {
59
+ items: FileQueryItem[];
60
+ nextCursor: string | null;
61
+ }
62
+
63
+ export type FilesystemEventType =
64
+ | 'file.created'
65
+ | 'file.updated'
66
+ | 'file.deleted'
67
+ | 'dir.created'
68
+ | 'dir.deleted'
69
+ | 'sync.error'
70
+ | 'sync.ignored'
71
+ | 'sync.suppressed'
72
+ | 'sync.stale'
73
+ | 'writeback.failed'
74
+ | 'writeback.succeeded';
75
+
76
+ export type EventOrigin = 'provider_sync' | 'agent_write' | 'system';
77
+
78
+ export interface FilesystemEvent {
79
+ eventId: string;
80
+ type: FilesystemEventType;
81
+ path: string;
82
+ revision: string;
83
+ origin?: EventOrigin;
84
+ provider?: string;
85
+ correlationId?: string;
86
+ timestamp: string;
87
+ }
88
+
89
+ export interface RelayfileApiErrorPayload {
90
+ code?: string;
91
+ message?: string;
92
+ details?: Record<string, unknown>;
93
+ }
94
+
95
+ export class RelayfileApiError extends Error {
96
+ readonly status: number;
97
+ readonly statusText: string;
98
+ readonly code?: string;
99
+ readonly details?: Record<string, unknown>;
100
+
101
+ constructor(response: Response, payload?: RelayfileApiErrorPayload) {
102
+ super(payload?.message ?? `${response.status} ${response.statusText}`);
103
+ this.name = 'RelayfileApiError';
104
+ this.status = response.status;
105
+ this.statusText = response.statusText;
106
+ this.code = payload?.code;
107
+ this.details = payload?.details;
108
+ }
109
+ }
110
+
111
+ export interface ListTreeOptions {
112
+ path?: string;
113
+ depth?: number;
114
+ cursor?: string;
115
+ correlationId?: string;
116
+ signal?: AbortSignal;
117
+ }
118
+
119
+ export interface ReadFileOptions {
120
+ correlationId?: string;
121
+ signal?: AbortSignal;
122
+ }
123
+
124
+ export interface QueryFilesOptions {
125
+ path?: string;
126
+ provider?: string;
127
+ relation?: string;
128
+ permission?: string;
129
+ comment?: string;
130
+ properties?: Record<string, string>;
131
+ cursor?: string;
132
+ limit?: number;
133
+ correlationId?: string;
134
+ signal?: AbortSignal;
135
+ }
136
+
137
+ type WebSocketEventName = 'event' | 'open' | 'close' | 'error';
138
+
139
+ type WebSocketHandlerMap = {
140
+ event: (event: FilesystemEvent) => void;
141
+ open: (event: Event) => void;
142
+ close: (event: CloseEvent) => void;
143
+ error: (event: Event | Error) => void;
144
+ };
145
+
146
+ export interface RelayfileWebSocketConnection {
147
+ close(code?: number, reason?: string): void;
148
+ on<TName extends WebSocketEventName>(event: TName, handler: WebSocketHandlerMap[TName]): () => void;
149
+ }
150
+
151
+ export interface ConnectWebSocketOptions {
152
+ token?: string;
153
+ onEvent?: (event: FilesystemEvent) => void;
154
+ }
155
+
156
+ export interface RelayfileClientOptions {
157
+ baseUrl?: string;
158
+ token?: string;
159
+ fetchImpl?: typeof fetch;
160
+ webSocketFactory?: (url: string) => WebSocket;
161
+ }
162
+
163
+ interface RequestOptions {
164
+ correlationId?: string;
165
+ signal?: AbortSignal;
166
+ }
167
+
168
+ type EnvMap = Record<string, string | undefined>;
169
+
170
+ function getEnv(): EnvMap {
171
+ if (typeof globalThis !== 'undefined') {
172
+ const processValue = (globalThis as { process?: { env?: EnvMap } }).process;
173
+ if (processValue?.env) {
174
+ return processValue.env;
175
+ }
176
+ }
177
+ return {};
178
+ }
179
+
180
+ function readEnvVariable(name: 'RELAYFILE_URL' | 'RELAYFILE_TOKEN'): string | undefined {
181
+ const value = getEnv()[name];
182
+ if (typeof value !== 'string') {
183
+ return undefined;
184
+ }
185
+ const trimmed = value.trim();
186
+ return trimmed === '' ? undefined : trimmed;
187
+ }
188
+
189
+ function buildQuery(params: Record<string, string | number | undefined>): string {
190
+ const search = new URLSearchParams();
191
+ for (const [key, value] of Object.entries(params)) {
192
+ if (value !== undefined && value !== '') {
193
+ search.set(key, String(value));
194
+ }
195
+ }
196
+ const encoded = search.toString();
197
+ return encoded === '' ? '' : `?${encoded}`;
198
+ }
199
+
200
+ function createCorrelationId(): string {
201
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
202
+ return `rf_${crypto.randomUUID()}`;
203
+ }
204
+ return `rf_${Date.now()}`;
205
+ }
206
+
207
+ function resolveBaseUrl(baseUrl?: string): string {
208
+ return (baseUrl ?? readEnvVariable('RELAYFILE_URL') ?? DEFAULT_RELAYFILE_URL).replace(/\/+$/, '');
209
+ }
210
+
211
+ function resolveToken(token?: string): string {
212
+ const resolved = token?.trim() || readEnvVariable('RELAYFILE_TOKEN');
213
+ if (!resolved) {
214
+ throw new Error('Relayfile authentication is not configured. Set RELAYFILE_TOKEN or pass a token explicitly.');
215
+ }
216
+ return resolved;
217
+ }
218
+
219
+ async function parseErrorPayload(response: Response): Promise<RelayfileApiErrorPayload | undefined> {
220
+ const contentType = response.headers.get('content-type') ?? '';
221
+ if (!contentType.toLowerCase().includes('application/json')) {
222
+ return undefined;
223
+ }
224
+ try {
225
+ return (await response.json()) as RelayfileApiErrorPayload;
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ class DefaultRelayfileWebSocketConnection implements RelayfileWebSocketConnection {
232
+ private readonly socket: WebSocket;
233
+ private readonly handlers: {
234
+ [K in WebSocketEventName]: Set<WebSocketHandlerMap[K]>;
235
+ } = {
236
+ event: new Set(),
237
+ open: new Set(),
238
+ close: new Set(),
239
+ error: new Set()
240
+ };
241
+
242
+ constructor(socket: WebSocket, onEvent?: (event: FilesystemEvent) => void) {
243
+ this.socket = socket;
244
+ if (onEvent) {
245
+ this.handlers.event.add(onEvent);
246
+ }
247
+
248
+ socket.addEventListener('open', (event) => {
249
+ for (const handler of this.handlers.open) {
250
+ handler(event);
251
+ }
252
+ });
253
+
254
+ socket.addEventListener('close', (event) => {
255
+ for (const handler of this.handlers.close) {
256
+ handler(event);
257
+ }
258
+ });
259
+
260
+ socket.addEventListener('error', (event) => {
261
+ const normalized = event instanceof ErrorEvent && event.error instanceof Error ? event.error : event;
262
+ for (const handler of this.handlers.error) {
263
+ handler(normalized);
264
+ }
265
+ });
266
+
267
+ socket.addEventListener('message', (event) => {
268
+ if (typeof event.data !== 'string') {
269
+ return;
270
+ }
271
+
272
+ let parsed: FilesystemEvent;
273
+ try {
274
+ const raw = JSON.parse(event.data) as Partial<FilesystemEvent> | null;
275
+ if (!raw || typeof raw !== 'object' || typeof raw.type !== 'string') {
276
+ throw new Error("Invalid Relayfile WebSocket event: missing required 'type' field.");
277
+ }
278
+ if (typeof raw.path !== 'string') {
279
+ throw new Error("Invalid Relayfile WebSocket event: missing required 'path' field.");
280
+ }
281
+ if (typeof raw.revision !== 'string') {
282
+ throw new Error("Invalid Relayfile WebSocket event: missing required 'revision' field.");
283
+ }
284
+ if (typeof raw.timestamp !== 'string') {
285
+ throw new Error("Invalid Relayfile WebSocket event: missing required 'timestamp' field.");
286
+ }
287
+
288
+ parsed = {
289
+ eventId: typeof raw.eventId === 'string' ? raw.eventId : '',
290
+ type: raw.type as FilesystemEventType,
291
+ path: raw.path,
292
+ revision: raw.revision,
293
+ origin: raw.origin,
294
+ provider: raw.provider,
295
+ correlationId: raw.correlationId,
296
+ timestamp: raw.timestamp
297
+ };
298
+ } catch (error) {
299
+ const normalized = error instanceof Error ? error : new Error('Failed to parse Relayfile WebSocket event payload.');
300
+ for (const handler of this.handlers.error) {
301
+ handler(normalized);
302
+ }
303
+ return;
304
+ }
305
+
306
+ for (const handler of this.handlers.event) {
307
+ handler(parsed);
308
+ }
309
+ });
310
+ }
311
+
312
+ close(code?: number, reason?: string): void {
313
+ this.socket.close(code, reason);
314
+ }
315
+
316
+ on<TName extends WebSocketEventName>(event: TName, handler: WebSocketHandlerMap[TName]): () => void {
317
+ this.handlers[event].add(handler);
318
+ return () => {
319
+ this.handlers[event].delete(handler);
320
+ };
321
+ }
322
+ }
323
+
324
+ export class RelayfileClient {
325
+ private readonly baseUrl: string;
326
+ private readonly token: string;
327
+ private readonly fetchImpl: typeof fetch;
328
+ private readonly webSocketFactory?: (url: string) => WebSocket;
329
+
330
+ constructor(options: RelayfileClientOptions = {}) {
331
+ this.baseUrl = resolveBaseUrl(options.baseUrl);
332
+ this.token = resolveToken(options.token);
333
+ this.fetchImpl = options.fetchImpl ?? fetch;
334
+ this.webSocketFactory = options.webSocketFactory;
335
+ }
336
+
337
+ async listTree(workspaceId: string, options: ListTreeOptions = {}): Promise<TreeResponse> {
338
+ const query = buildQuery({
339
+ path: options.path ?? '/',
340
+ depth: options.depth,
341
+ cursor: options.cursor
342
+ });
343
+
344
+ return this.request<TreeResponse>(
345
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/tree${query}`,
346
+ options
347
+ );
348
+ }
349
+
350
+ async readFile(workspaceId: string, path: string, options: ReadFileOptions = {}): Promise<FileReadResponse> {
351
+ const query = buildQuery({ path });
352
+ return this.request<FileReadResponse>(
353
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/file${query}`,
354
+ options
355
+ );
356
+ }
357
+
358
+ async queryFiles(workspaceId: string, options: QueryFilesOptions = {}): Promise<FileQueryResponse> {
359
+ const search = new URLSearchParams();
360
+ if (options.path !== undefined) search.set('path', options.path);
361
+ if (options.provider !== undefined) search.set('provider', options.provider);
362
+ if (options.relation !== undefined) search.set('relation', options.relation);
363
+ if (options.permission !== undefined) search.set('permission', options.permission);
364
+ if (options.comment !== undefined) search.set('comment', options.comment);
365
+ if (options.cursor !== undefined) search.set('cursor', options.cursor);
366
+ if (options.limit !== undefined) search.set('limit', String(options.limit));
367
+ if (options.properties) {
368
+ for (const [key, value] of Object.entries(options.properties)) {
369
+ if (key !== '' && value !== undefined) {
370
+ search.set(`property.${key}`, value);
371
+ }
372
+ }
373
+ }
374
+
375
+ const query = search.toString();
376
+ return this.request<FileQueryResponse>(
377
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/query${query === '' ? '' : `?${query}`}`,
378
+ options
379
+ );
380
+ }
381
+
382
+ connectWebSocket(
383
+ workspaceId: string,
384
+ options: ConnectWebSocketOptions = {}
385
+ ): RelayfileWebSocketConnection {
386
+ const WebSocketImpl = this.webSocketFactory ?? getGlobalWebSocket();
387
+ const url = new URL(`${this.baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/fs/ws`);
388
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
389
+ url.searchParams.set('token', options.token?.trim() || this.token);
390
+
391
+ const socket = WebSocketImpl(url.toString());
392
+ return new DefaultRelayfileWebSocketConnection(socket, options.onEvent);
393
+ }
394
+
395
+ private async request<T>(path: string, options: RequestOptions): Promise<T> {
396
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
397
+ method: 'GET',
398
+ signal: options.signal,
399
+ headers: {
400
+ Accept: 'application/json',
401
+ Authorization: `Bearer ${this.token}`,
402
+ 'X-Correlation-Id': options.correlationId ?? createCorrelationId()
403
+ }
404
+ });
405
+
406
+ if (!response.ok) {
407
+ throw new RelayfileApiError(response, await parseErrorPayload(response));
408
+ }
409
+
410
+ return (await response.json()) as T;
411
+ }
412
+ }
413
+
414
+ function getGlobalWebSocket(): (url: string) => WebSocket {
415
+ if (typeof WebSocket !== 'function') {
416
+ throw new Error('WebSocket is not available in this runtime. Provide a webSocketFactory or run in a browser-like environment.');
417
+ }
418
+
419
+ return (url: string) => new WebSocket(url);
420
+ }
421
+
422
+ let defaultClient: RelayfileClient | undefined;
423
+
424
+ export function createRelayfileClient(options: RelayfileClientOptions = {}): RelayfileClient {
425
+ return new RelayfileClient(options);
426
+ }
427
+
428
+ function getDefaultClient(): RelayfileClient {
429
+ if (!defaultClient) {
430
+ defaultClient = createRelayfileClient();
431
+ }
432
+ return defaultClient;
433
+ }
434
+
435
+ export function listTree(workspaceId: string, options: ListTreeOptions = {}): Promise<TreeResponse> {
436
+ return getDefaultClient().listTree(workspaceId, options);
437
+ }
438
+
439
+ export function readFile(
440
+ workspaceId: string,
441
+ path: string,
442
+ options: ReadFileOptions = {}
443
+ ): Promise<FileReadResponse> {
444
+ return getDefaultClient().readFile(workspaceId, path, options);
445
+ }
446
+
447
+ export function queryFiles(
448
+ workspaceId: string,
449
+ options: QueryFilesOptions = {}
450
+ ): Promise<FileQueryResponse> {
451
+ return getDefaultClient().queryFiles(workspaceId, options);
452
+ }
453
+
454
+ export function connectWebSocket(
455
+ workspaceId: string,
456
+ options: ConnectWebSocketOptions = {}
457
+ ): RelayfileWebSocketConnection {
458
+ return getDefaultClient().connectWebSocket(workspaceId, options);
459
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./src/*"]
19
+ }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
22
+ "exclude": ["node_modules"]
23
+ }
@@ -0,0 +1,10 @@
1
+ name = "relayfile-file-observer"
2
+ compatibility_date = "2024-12-01"
3
+
4
+ routes = [
5
+ { pattern = "files.relayfile.dev/*", zone_name = "relayfile.dev" },
6
+ { pattern = "staging-files.relayfile.dev/*", zone_name = "relayfile.dev" },
7
+ ]
8
+
9
+ [vars]
10
+ FILE_OBSERVER_ORIGIN = "https://relayfile-file-observer.pages.dev"