@brftech/filex-core 0.1.74 → 0.1.76
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/dist/{ArchiveViewer-o8CcI64y.js → ArchiveViewer-BmpDoIWA.js} +2 -2
- package/dist/{ArchiveViewer-o8CcI64y.js.map → ArchiveViewer-BmpDoIWA.js.map} +1 -1
- package/dist/{CsvViewer-u8oNfRP1.js → CsvViewer-BAMxNxy4.js} +2 -2
- package/dist/{CsvViewer-u8oNfRP1.js.map → CsvViewer-BAMxNxy4.js.map} +1 -1
- package/dist/{DrawioViewer-BXgp480V.js → DrawioViewer-BIm8G98S.js} +2 -2
- package/dist/{DrawioViewer-BXgp480V.js.map → DrawioViewer-BIm8G98S.js.map} +1 -1
- package/dist/{EpubViewer-CSs-I3ZL.js → EpubViewer-D2OggB7W.js} +2 -2
- package/dist/{EpubViewer-CSs-I3ZL.js.map → EpubViewer-D2OggB7W.js.map} +1 -1
- package/dist/{IpynbViewer-DHY9HH_F.js → IpynbViewer-Dcew5gZQ.js} +2 -2
- package/dist/{IpynbViewer-DHY9HH_F.js.map → IpynbViewer-Dcew5gZQ.js.map} +1 -1
- package/dist/{MermaidViewer-Bly86gd2.js → MermaidViewer-iG7VuHeu.js} +2 -2
- package/dist/{MermaidViewer-Bly86gd2.js.map → MermaidViewer-iG7VuHeu.js.map} +1 -1
- package/dist/{PsdViewer-CDQGyl0o.js → PsdViewer-C-92G5j0.js} +2 -2
- package/dist/{PsdViewer-CDQGyl0o.js.map → PsdViewer-C-92G5j0.js.map} +1 -1
- package/dist/{TiffViewer-Cs9Ze9-W.js → TiffViewer-uOJVggkp.js} +2 -2
- package/dist/{TiffViewer-Cs9Ze9-W.js.map → TiffViewer-uOJVggkp.js.map} +1 -1
- package/dist/{Viewer3D-B54FtoDP.js → Viewer3D-RnzhEk_6.js} +2 -2
- package/dist/{Viewer3D-B54FtoDP.js.map → Viewer3D-RnzhEk_6.js.map} +1 -1
- package/dist/filex-core.js +1 -1
- package/dist/filex-core.umd.cjs +38 -38
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-B6jiFQPw.js +5758 -0
- package/dist/index-B6jiFQPw.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +31 -0
- package/src/components/PresenceBar.vue +121 -0
- package/src/composables/useFileApi.ts +15 -0
- package/src/composables/useRealtime.ts +94 -0
- package/src/lib/realtime.ts +223 -0
- package/src/styles/base.css +7 -0
- package/dist/index-BSfDPPtn.js +0 -5523
- package/dist/index-BSfDPPtn.js.map +0 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Realtime (WebSocket) client for filex live collaboration, bundled into the
|
|
2
|
+
// core explorer so EVERY consumer — the native panel AND the vendored
|
|
3
|
+
// webcomponent embedded in host apps — gets live folder updates + presence.
|
|
4
|
+
//
|
|
5
|
+
// Auth: the browser's native WebSocket can't set an Authorization header and,
|
|
6
|
+
// when embedded, connects cross-origin to fm.brf.sh. So instead of a header we
|
|
7
|
+
// fetch a short-lived, single-use TICKET through the host's normal API (which
|
|
8
|
+
// injects the real token server-side) and open `wss://…/api/ws?ticket=<t>`.
|
|
9
|
+
// The durable token never reaches the browser.
|
|
10
|
+
//
|
|
11
|
+
// Degradation: if the ticket or the socket is unavailable (old backend, blocked
|
|
12
|
+
// upgrade, unsupported env), `onFallback(true)` fires so the consumer can fall
|
|
13
|
+
// back to plain API polling. The page always keeps working — every send is
|
|
14
|
+
// guarded, connect() is wrapped, reconnects are capped.
|
|
15
|
+
|
|
16
|
+
export interface PresenceUser {
|
|
17
|
+
id: number;
|
|
18
|
+
name: string;
|
|
19
|
+
file?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ChangeMessage {
|
|
23
|
+
type: 'change';
|
|
24
|
+
path: string;
|
|
25
|
+
action: string; // create | delete | rename | move | upload | modify
|
|
26
|
+
name?: string;
|
|
27
|
+
new_name?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PresenceMessage {
|
|
31
|
+
type: 'presence';
|
|
32
|
+
path: string;
|
|
33
|
+
users: PresenceUser[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WsTicket {
|
|
37
|
+
ticket: string;
|
|
38
|
+
ws_url: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RealtimeHandlers {
|
|
42
|
+
onChange?: (msg: ChangeMessage) => void;
|
|
43
|
+
onPresence?: (msg: PresenceMessage) => void;
|
|
44
|
+
onStatus?: (connected: boolean) => void;
|
|
45
|
+
/** Fires true when the live socket is unavailable (consumer should poll),
|
|
46
|
+
* false when a live socket is (re)established. */
|
|
47
|
+
onFallback?: (active: boolean) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RealtimeOptions {
|
|
51
|
+
/** Fetch a fresh ticket (single-use) via the host API. null → no live socket. */
|
|
52
|
+
getTicket: () => Promise<WsTicket | null>;
|
|
53
|
+
handlers: RealtimeHandlers;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PING_INTERVAL_MS = 25_000;
|
|
57
|
+
const MAX_BACKOFF_MS = 15_000;
|
|
58
|
+
const MAX_RETRIES = 6;
|
|
59
|
+
|
|
60
|
+
export class RealtimeClient {
|
|
61
|
+
private ws: WebSocket | null = null;
|
|
62
|
+
private opts: RealtimeOptions;
|
|
63
|
+
private closed = false;
|
|
64
|
+
private connecting = false;
|
|
65
|
+
private retries = 0;
|
|
66
|
+
private fallback = false;
|
|
67
|
+
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
68
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
|
|
70
|
+
// Last-known intent, replayed on (re)connect.
|
|
71
|
+
private currentPath: string | null = null;
|
|
72
|
+
private currentFocus: string | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(opts: RealtimeOptions) {
|
|
75
|
+
this.opts = opts;
|
|
76
|
+
void this.connect();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Subscribe to a folder ("<adapter>://<dir>"). Swaps the active room and
|
|
80
|
+
* resets focus. Passing null unsubscribes (drive list / trash have no room). */
|
|
81
|
+
subscribe(path: string | null): void {
|
|
82
|
+
this.currentPath = path;
|
|
83
|
+
this.currentFocus = null;
|
|
84
|
+
if (path) this.send({ type: 'subscribe', path });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Report the file the user is focused on (or null to clear). */
|
|
88
|
+
setFocus(file: string | null): void {
|
|
89
|
+
this.currentFocus = file;
|
|
90
|
+
this.send({ type: 'focus', file });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Tear down permanently — call on unmount. */
|
|
94
|
+
close(): void {
|
|
95
|
+
this.closed = true;
|
|
96
|
+
this.clearTimers();
|
|
97
|
+
this.dropSocket();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async connect(): Promise<void> {
|
|
101
|
+
if (this.closed || this.connecting || this.ws) return;
|
|
102
|
+
this.connecting = true;
|
|
103
|
+
let ticket: WsTicket | null = null;
|
|
104
|
+
try {
|
|
105
|
+
ticket = await this.opts.getTicket();
|
|
106
|
+
} catch {
|
|
107
|
+
ticket = null;
|
|
108
|
+
}
|
|
109
|
+
this.connecting = false;
|
|
110
|
+
if (this.closed) return;
|
|
111
|
+
if (!ticket || !ticket.ticket || !ticket.ws_url) {
|
|
112
|
+
this.fail();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sep = ticket.ws_url.includes('?') ? '&' : '?';
|
|
117
|
+
let ws: WebSocket;
|
|
118
|
+
try {
|
|
119
|
+
ws = new WebSocket(`${ticket.ws_url}${sep}ticket=${encodeURIComponent(ticket.ticket)}`);
|
|
120
|
+
} catch {
|
|
121
|
+
this.fail();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.ws = ws;
|
|
125
|
+
|
|
126
|
+
ws.onopen = () => {
|
|
127
|
+
this.retries = 0;
|
|
128
|
+
this.setFallback(false);
|
|
129
|
+
this.opts.handlers.onStatus?.(true);
|
|
130
|
+
if (this.currentPath) this.send({ type: 'subscribe', path: this.currentPath });
|
|
131
|
+
if (this.currentFocus !== null) this.send({ type: 'focus', file: this.currentFocus });
|
|
132
|
+
this.startPing();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
ws.onmessage = (ev: MessageEvent) => {
|
|
136
|
+
let msg: unknown;
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
|
139
|
+
} catch {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const m = msg as { type?: string };
|
|
143
|
+
if (m?.type === 'change') this.opts.handlers.onChange?.(msg as ChangeMessage);
|
|
144
|
+
else if (m?.type === 'presence') this.opts.handlers.onPresence?.(msg as PresenceMessage);
|
|
145
|
+
// pong / error frames are intentionally ignored by the UI.
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
ws.onerror = () => {
|
|
149
|
+
// onclose fires next; reconnect is handled there.
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
ws.onclose = () => {
|
|
153
|
+
this.stopPing();
|
|
154
|
+
this.ws = null;
|
|
155
|
+
this.opts.handlers.onStatus?.(false);
|
|
156
|
+
this.fail();
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// fail schedules a capped reconnect; after MAX_RETRIES it gives up on the live
|
|
161
|
+
// socket and flips to fallback (polling) mode.
|
|
162
|
+
private fail(): void {
|
|
163
|
+
if (this.closed || this.reconnectTimer) return;
|
|
164
|
+
if (this.retries >= MAX_RETRIES) {
|
|
165
|
+
this.setFallback(true);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const delay = Math.min(1000 * 2 ** this.retries, MAX_BACKOFF_MS);
|
|
169
|
+
this.retries += 1;
|
|
170
|
+
this.reconnectTimer = setTimeout(() => {
|
|
171
|
+
this.reconnectTimer = null;
|
|
172
|
+
void this.connect();
|
|
173
|
+
}, delay);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private setFallback(active: boolean): void {
|
|
177
|
+
if (this.fallback === active) return;
|
|
178
|
+
this.fallback = active;
|
|
179
|
+
this.opts.handlers.onFallback?.(active);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private send(obj: unknown): void {
|
|
183
|
+
const ws = this.ws;
|
|
184
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
185
|
+
try {
|
|
186
|
+
ws.send(JSON.stringify(obj));
|
|
187
|
+
} catch {
|
|
188
|
+
/* a failed send just means a missed live update */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private startPing(): void {
|
|
193
|
+
this.stopPing();
|
|
194
|
+
this.pingTimer = setInterval(() => this.send({ type: 'ping' }), PING_INTERVAL_MS);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private stopPing(): void {
|
|
198
|
+
if (this.pingTimer) {
|
|
199
|
+
clearInterval(this.pingTimer);
|
|
200
|
+
this.pingTimer = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private dropSocket(): void {
|
|
205
|
+
if (this.ws) {
|
|
206
|
+
try {
|
|
207
|
+
this.ws.onclose = null; // suppress reconnect
|
|
208
|
+
this.ws.close();
|
|
209
|
+
} catch {
|
|
210
|
+
/* ignore */
|
|
211
|
+
}
|
|
212
|
+
this.ws = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private clearTimers(): void {
|
|
217
|
+
this.stopPing();
|
|
218
|
+
if (this.reconnectTimer) {
|
|
219
|
+
clearTimeout(this.reconnectTimer);
|
|
220
|
+
this.reconnectTimer = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/styles/base.css
CHANGED
|
@@ -26,6 +26,13 @@
|
|
|
26
26
|
background: var(--fe-bg);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/* Live-presence strip (who else is in this folder) — shown under the breadcrumb. */
|
|
30
|
+
.fe__presence {
|
|
31
|
+
flex: 0 0 auto;
|
|
32
|
+
padding: 0.25rem 0.75rem;
|
|
33
|
+
border-bottom: 1px solid var(--fe-border, rgba(0, 0, 0, 0.06));
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
/* Initial-load spinner (shown until the first listing arrives). */
|
|
30
37
|
.fe__loading {
|
|
31
38
|
display: flex;
|