Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
78 lines
1.6 KiB
TypeScript
78 lines
1.6 KiB
TypeScript
export interface SSEEvent {
|
|
type: string;
|
|
data: unknown;
|
|
}
|
|
|
|
export interface SSEClientOptions {
|
|
path: string;
|
|
onEvent: (event: SSEEvent) => void;
|
|
onReconnect?: () => void;
|
|
}
|
|
|
|
// Wrapper над EventSource с экспоненциальным backoff и догоном через REST.
|
|
export class SSEClient {
|
|
private es: EventSource | null = null;
|
|
private stopped = false;
|
|
private attempt = 0;
|
|
private readonly knownEvents = [
|
|
'photo.created',
|
|
'photo.deleted',
|
|
'comment.created',
|
|
'like.added',
|
|
'like.removed',
|
|
'member.joined',
|
|
'member.left',
|
|
'member.updated',
|
|
'album.updated',
|
|
'album.deleted',
|
|
'notification'
|
|
];
|
|
|
|
constructor(private opts: SSEClientOptions) {}
|
|
|
|
connect(token: string) {
|
|
this.stopped = false;
|
|
this.open(token);
|
|
}
|
|
|
|
private open(token: string) {
|
|
if (this.stopped) return;
|
|
const url = `${this.opts.path}?token=${encodeURIComponent(token)}`;
|
|
this.es = new EventSource(url);
|
|
|
|
this.es.onopen = () => {
|
|
if (this.attempt > 0) {
|
|
this.opts.onReconnect?.();
|
|
}
|
|
this.attempt = 0;
|
|
};
|
|
|
|
for (const name of this.knownEvents) {
|
|
this.es.addEventListener(name, (ev) => {
|
|
let data: unknown = null;
|
|
try {
|
|
data = JSON.parse((ev as MessageEvent).data);
|
|
} catch {
|
|
data = (ev as MessageEvent).data;
|
|
}
|
|
this.opts.onEvent({ type: name, data });
|
|
});
|
|
}
|
|
|
|
this.es.onerror = () => {
|
|
this.es?.close();
|
|
this.es = null;
|
|
if (this.stopped) return;
|
|
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
|
|
this.attempt += 1;
|
|
setTimeout(() => this.open(token), delay);
|
|
};
|
|
}
|
|
|
|
close() {
|
|
this.stopped = true;
|
|
this.es?.close();
|
|
this.es = null;
|
|
}
|
|
}
|