@svadmin/core 0.0.6 → 0.3.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.
- package/package.json +4 -1
- package/src/audit.ts +37 -0
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.ts +65 -15
- package/src/data-transfer.ts +148 -70
- package/src/form-hooks.svelte.ts +266 -0
- package/src/helpers.ts +157 -0
- package/src/hook-utils.svelte.ts +121 -0
- package/src/hooks.svelte.ts +277 -530
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +18 -0
- package/src/index.ts +39 -8
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/live-sse.ts +146 -0
- package/src/live-websocket.ts +141 -0
- package/src/live.test.ts +134 -0
- package/src/live.ts +60 -8
- package/src/mutation-hooks.svelte.ts +301 -0
- package/src/options.ts +95 -0
- package/src/query-hooks.svelte.ts +213 -0
- package/src/query-keys.ts +22 -0
- package/src/resource-types.test.ts +79 -0
- package/src/router.ts +28 -4
- package/src/routing-hooks.svelte.ts +99 -0
- package/src/table-hooks.svelte.ts +178 -0
- package/src/toast.svelte.ts +8 -4
- package/src/types.ts +124 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +39 -3
- package/src/useStepsForm.svelte.ts +71 -0
- package/src/utility-hooks.svelte.ts +161 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI Schema → ResourceDefinition converter
|
|
3
|
+
*
|
|
4
|
+
* Parses an OpenAPI 3.x JSON schema and generates ResourceDefinition[]
|
|
5
|
+
* by analyzing the schema's paths and components.
|
|
6
|
+
*/
|
|
7
|
+
import type { FieldDefinition, ResourceDefinition } from './types';
|
|
8
|
+
|
|
9
|
+
// ─── OpenAPI Types (subset) ──────────────────────────────────
|
|
10
|
+
|
|
11
|
+
interface OpenAPISchema {
|
|
12
|
+
openapi?: string;
|
|
13
|
+
info?: { title?: string; version?: string };
|
|
14
|
+
paths?: Record<string, OpenAPIPathItem>;
|
|
15
|
+
components?: {
|
|
16
|
+
schemas?: Record<string, OpenAPISchemaObject>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OpenAPIPathItem {
|
|
21
|
+
get?: OpenAPIOperation;
|
|
22
|
+
post?: OpenAPIOperation;
|
|
23
|
+
put?: OpenAPIOperation;
|
|
24
|
+
patch?: OpenAPIOperation;
|
|
25
|
+
delete?: OpenAPIOperation;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface OpenAPIOperation {
|
|
29
|
+
operationId?: string;
|
|
30
|
+
tags?: string[];
|
|
31
|
+
parameters?: OpenAPIParameter[];
|
|
32
|
+
requestBody?: {
|
|
33
|
+
content?: Record<string, { schema?: OpenAPISchemaObject | OpenAPIRef }>;
|
|
34
|
+
};
|
|
35
|
+
responses?: Record<string, {
|
|
36
|
+
content?: Record<string, { schema?: OpenAPISchemaObject | OpenAPIRef }>;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface OpenAPIParameter {
|
|
41
|
+
name: string;
|
|
42
|
+
in: string;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
schema?: OpenAPISchemaObject;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface OpenAPISchemaObject {
|
|
48
|
+
type?: string;
|
|
49
|
+
format?: string;
|
|
50
|
+
properties?: Record<string, OpenAPISchemaObject | OpenAPIRef>;
|
|
51
|
+
items?: OpenAPISchemaObject | OpenAPIRef;
|
|
52
|
+
required?: string[];
|
|
53
|
+
enum?: (string | number)[];
|
|
54
|
+
$ref?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
nullable?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface OpenAPIRef {
|
|
60
|
+
$ref: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isRef(obj: unknown): obj is OpenAPIRef {
|
|
64
|
+
return obj !== null && typeof obj === 'object' && '$ref' in obj && typeof (obj as OpenAPIRef).$ref === 'string';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Schema Resolution ───────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function resolveRef(ref: string, root: OpenAPISchema): OpenAPISchemaObject | null {
|
|
70
|
+
// $ref format: "#/components/schemas/Post"
|
|
71
|
+
const parts = ref.replace('#/', '').split('/');
|
|
72
|
+
let current: unknown = root;
|
|
73
|
+
for (const part of parts) {
|
|
74
|
+
if (current === null || typeof current !== 'object') return null;
|
|
75
|
+
current = (current as Record<string, unknown>)[part];
|
|
76
|
+
if (!current) return null;
|
|
77
|
+
}
|
|
78
|
+
return current as OpenAPISchemaObject;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveSchema(schemaOrRef: OpenAPISchemaObject | OpenAPIRef, root: OpenAPISchema): OpenAPISchemaObject | null {
|
|
82
|
+
if (isRef(schemaOrRef)) {
|
|
83
|
+
return resolveRef(schemaOrRef.$ref, root);
|
|
84
|
+
}
|
|
85
|
+
return schemaOrRef;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Type Mapping ────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function mapOpenAPITypeToFieldType(
|
|
91
|
+
key: string,
|
|
92
|
+
schema: OpenAPISchemaObject,
|
|
93
|
+
root: OpenAPISchema
|
|
94
|
+
): FieldDefinition['type'] {
|
|
95
|
+
const { type, format } = schema;
|
|
96
|
+
|
|
97
|
+
// Enum → select
|
|
98
|
+
if (schema.enum && schema.enum.length > 0) return 'select';
|
|
99
|
+
|
|
100
|
+
// Array types
|
|
101
|
+
if (type === 'array') {
|
|
102
|
+
if (schema.items) {
|
|
103
|
+
const itemSchema = resolveSchema(schema.items, root);
|
|
104
|
+
if (itemSchema?.type === 'string') {
|
|
105
|
+
const lk = key.toLowerCase();
|
|
106
|
+
if (lk.includes('image') || lk.includes('photo')) return 'images';
|
|
107
|
+
return 'tags';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 'tags';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Object → json
|
|
114
|
+
if (type === 'object') return 'json';
|
|
115
|
+
|
|
116
|
+
// Boolean
|
|
117
|
+
if (type === 'boolean') return 'boolean';
|
|
118
|
+
|
|
119
|
+
// Number/Integer
|
|
120
|
+
if (type === 'number' || type === 'integer') return 'number';
|
|
121
|
+
|
|
122
|
+
// String with format
|
|
123
|
+
if (type === 'string') {
|
|
124
|
+
if (format === 'date' || format === 'date-time') return 'date';
|
|
125
|
+
if (format === 'email') return 'email';
|
|
126
|
+
if (format === 'uri' || format === 'url') return 'url';
|
|
127
|
+
if (format === 'binary' || format === 'byte') return 'image';
|
|
128
|
+
|
|
129
|
+
// Heuristic from key name
|
|
130
|
+
const lk = key.toLowerCase();
|
|
131
|
+
if (lk.includes('email')) return 'email';
|
|
132
|
+
if (lk.includes('phone') || lk.includes('tel')) return 'phone';
|
|
133
|
+
if (lk.includes('url') || lk.includes('link') || lk.includes('website')) return 'url';
|
|
134
|
+
if (lk.includes('avatar') || lk.includes('image') || lk.includes('photo') || lk.includes('logo')) return 'image';
|
|
135
|
+
if (lk.includes('color') || lk.includes('colour')) return 'color';
|
|
136
|
+
if (lk.includes('description') || lk.includes('content') || lk.includes('body') || lk.includes('bio')) return 'textarea';
|
|
137
|
+
if (lk === 'created_at' || lk === 'updated_at' || lk.endsWith('_at') || lk.endsWith('_date')) return 'date';
|
|
138
|
+
|
|
139
|
+
return 'text';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return 'text';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Core: Parse OpenAPI Schema ──────────────────────────────
|
|
146
|
+
|
|
147
|
+
export interface InferFromOpenAPIOptions {
|
|
148
|
+
/** Only infer resources matching these schema names */
|
|
149
|
+
include?: string[];
|
|
150
|
+
/** Exclude these schema names */
|
|
151
|
+
exclude?: string[];
|
|
152
|
+
/** Custom primary key (default: 'id') */
|
|
153
|
+
primaryKey?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse an OpenAPI 3.x JSON schema and produce ResourceDefinition[].
|
|
158
|
+
*/
|
|
159
|
+
export function inferFromOpenAPI(
|
|
160
|
+
spec: OpenAPISchema,
|
|
161
|
+
options: InferFromOpenAPIOptions = {}
|
|
162
|
+
): ResourceDefinition[] {
|
|
163
|
+
const { primaryKey = 'id', include, exclude } = options;
|
|
164
|
+
const schemas = spec.components?.schemas ?? {};
|
|
165
|
+
const resources: ResourceDefinition[] = [];
|
|
166
|
+
|
|
167
|
+
// Detect which schemas have CRUD paths
|
|
168
|
+
const resourcePaths = detectResourcePaths(spec);
|
|
169
|
+
|
|
170
|
+
for (const [schemaName, schemaObj] of Object.entries(schemas)) {
|
|
171
|
+
// Apply include/exclude filters
|
|
172
|
+
if (include && !include.includes(schemaName)) continue;
|
|
173
|
+
if (exclude && exclude.includes(schemaName)) continue;
|
|
174
|
+
|
|
175
|
+
const resolved = resolveSchema(schemaObj as OpenAPISchemaObject | OpenAPIRef, spec);
|
|
176
|
+
if (!resolved || resolved.type !== 'object' || !resolved.properties) continue;
|
|
177
|
+
|
|
178
|
+
const fields: FieldDefinition[] = [];
|
|
179
|
+
const requiredFields = new Set(resolved.required ?? []);
|
|
180
|
+
|
|
181
|
+
for (const [propName, propSchemaOrRef] of Object.entries(resolved.properties)) {
|
|
182
|
+
const propSchema = resolveSchema(propSchemaOrRef, spec);
|
|
183
|
+
if (!propSchema) continue;
|
|
184
|
+
|
|
185
|
+
const fieldType = mapOpenAPITypeToFieldType(propName, propSchema, spec);
|
|
186
|
+
|
|
187
|
+
// Detect relation from $ref or _id suffix
|
|
188
|
+
let relatedResource: string | null = null;
|
|
189
|
+
if (isRef(propSchemaOrRef)) {
|
|
190
|
+
const refName = propSchemaOrRef.$ref.split('/').pop() ?? '';
|
|
191
|
+
relatedResource = refName.toLowerCase() + 's';
|
|
192
|
+
} else if (propName.endsWith('_id') || propName.endsWith('Id')) {
|
|
193
|
+
const base = propName.replace(/_id$|Id$/, '');
|
|
194
|
+
relatedResource = base.endsWith('s') ? base : base + 's';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const field: FieldDefinition = {
|
|
198
|
+
key: propName,
|
|
199
|
+
label: humanize(propName),
|
|
200
|
+
type: relatedResource ? 'relation' : fieldType,
|
|
201
|
+
required: requiredFields.has(propName),
|
|
202
|
+
sortable: ['text', 'number', 'date'].includes(fieldType),
|
|
203
|
+
searchable: ['text', 'email'].includes(fieldType),
|
|
204
|
+
showInList: propName !== primaryKey && fieldType !== 'textarea' && fieldType !== 'json',
|
|
205
|
+
showInForm: propName !== primaryKey,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (relatedResource) {
|
|
209
|
+
field.resource = relatedResource;
|
|
210
|
+
field.optionLabel = 'name';
|
|
211
|
+
field.optionValue = 'id';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (propSchema.enum) {
|
|
215
|
+
field.options = propSchema.enum.map(v => ({
|
|
216
|
+
label: String(v).charAt(0).toUpperCase() + String(v).slice(1),
|
|
217
|
+
value: v as string | number,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fields.push(field);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Sort: primaryKey first
|
|
225
|
+
fields.sort((a, b) => {
|
|
226
|
+
if (a.key === primaryKey) return -1;
|
|
227
|
+
if (b.key === primaryKey) return 1;
|
|
228
|
+
return 0;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Check path-based CRUD capabilities
|
|
232
|
+
const pathInfo = resourcePaths.get(schemaName.toLowerCase()) ??
|
|
233
|
+
resourcePaths.get(schemaName.toLowerCase() + 's');
|
|
234
|
+
|
|
235
|
+
resources.push({
|
|
236
|
+
name: schemaName.toLowerCase() + 's',
|
|
237
|
+
label: schemaName,
|
|
238
|
+
primaryKey,
|
|
239
|
+
fields,
|
|
240
|
+
canCreate: pathInfo?.hasPost ?? true,
|
|
241
|
+
canEdit: pathInfo?.hasPut ?? true,
|
|
242
|
+
canDelete: pathInfo?.hasDelete ?? true,
|
|
243
|
+
canShow: pathInfo?.hasGet ?? true,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return resources;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Path Detection ──────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
interface PathInfo {
|
|
253
|
+
hasGet: boolean;
|
|
254
|
+
hasPost: boolean;
|
|
255
|
+
hasPut: boolean;
|
|
256
|
+
hasDelete: boolean;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function detectResourcePaths(spec: OpenAPISchema): Map<string, PathInfo> {
|
|
260
|
+
const result = new Map<string, PathInfo>();
|
|
261
|
+
if (!spec.paths) return result;
|
|
262
|
+
|
|
263
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
264
|
+
// Extract resource name from path like /api/posts or /posts/{id}
|
|
265
|
+
const segments = path.split('/').filter(Boolean);
|
|
266
|
+
const resourceSegment = segments.find(s => !s.startsWith('{') && s !== 'api' && s !== 'v1' && s !== 'v2');
|
|
267
|
+
if (!resourceSegment) continue;
|
|
268
|
+
|
|
269
|
+
const name = resourceSegment.toLowerCase();
|
|
270
|
+
const existing = result.get(name) ?? { hasGet: false, hasPost: false, hasPut: false, hasDelete: false };
|
|
271
|
+
|
|
272
|
+
if (methods.get) existing.hasGet = true;
|
|
273
|
+
if (methods.post) existing.hasPost = true;
|
|
274
|
+
if (methods.put || methods.patch) existing.hasPut = true;
|
|
275
|
+
if (methods.delete) existing.hasDelete = true;
|
|
276
|
+
|
|
277
|
+
result.set(name, existing);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Utilities ───────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function humanize(key: string): string {
|
|
286
|
+
return key
|
|
287
|
+
.replace(/_/g, ' ')
|
|
288
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
289
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
290
|
+
}
|
package/src/live-sse.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) LiveProvider
|
|
3
|
+
*
|
|
4
|
+
* Connects to an SSE endpoint and dispatches LiveEvents to subscribers.
|
|
5
|
+
* The server should emit events as JSON matching the LiveEvent shape:
|
|
6
|
+
* data: { "type": "INSERT", "resource": "posts", "payload": {...} }
|
|
7
|
+
*
|
|
8
|
+
* Optionally, named SSE events can be used:
|
|
9
|
+
* event: posts
|
|
10
|
+
* data: { "type": "INSERT", "payload": {...} }
|
|
11
|
+
*/
|
|
12
|
+
import type { LiveProvider, LiveEvent } from './live';
|
|
13
|
+
|
|
14
|
+
export interface SSELiveProviderOptions {
|
|
15
|
+
/** SSE endpoint URL, e.g. "https://api.example.com/events" */
|
|
16
|
+
url: string;
|
|
17
|
+
/** Optional EventSource init options (e.g. withCredentials) */
|
|
18
|
+
eventSourceInit?: EventSourceInit;
|
|
19
|
+
/** Optional callback when connection opens */
|
|
20
|
+
onOpen?: () => void;
|
|
21
|
+
/** Optional callback for connection errors */
|
|
22
|
+
onError?: (event: Event) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProvider & { disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected' } {
|
|
26
|
+
const { url, eventSourceInit } = options;
|
|
27
|
+
|
|
28
|
+
type Callback = (event: LiveEvent) => void;
|
|
29
|
+
const subscribers = new Map<string, Set<Callback>>();
|
|
30
|
+
let eventSource: EventSource | null = null;
|
|
31
|
+
let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
|
|
32
|
+
|
|
33
|
+
function connect() {
|
|
34
|
+
if (eventSource) return;
|
|
35
|
+
if (typeof EventSource === 'undefined') return;
|
|
36
|
+
|
|
37
|
+
status = 'connecting';
|
|
38
|
+
eventSource = new EventSource(url, eventSourceInit);
|
|
39
|
+
|
|
40
|
+
eventSource.onopen = () => {
|
|
41
|
+
status = 'connected';
|
|
42
|
+
options.onOpen?.();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Listen for generic "message" events
|
|
46
|
+
eventSource.onmessage = (msgEvent) => {
|
|
47
|
+
dispatchEvent(msgEvent.data);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
eventSource.onerror = (event) => {
|
|
51
|
+
// EventSource auto-reconnects by default
|
|
52
|
+
if (eventSource?.readyState === EventSource.CLOSED) {
|
|
53
|
+
status = 'disconnected';
|
|
54
|
+
}
|
|
55
|
+
options.onError?.(event);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Also listen for resource-specific named events
|
|
59
|
+
for (const resource of subscribers.keys()) {
|
|
60
|
+
if (resource !== '*') {
|
|
61
|
+
addNamedListener(resource);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function addNamedListener(resource: string) {
|
|
67
|
+
if (!eventSource) return;
|
|
68
|
+
eventSource.addEventListener(resource, ((event: MessageEvent) => {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(event.data);
|
|
71
|
+
const liveEvent: LiveEvent = {
|
|
72
|
+
type: parsed.type,
|
|
73
|
+
resource: parsed.resource ?? resource,
|
|
74
|
+
payload: parsed.payload ?? parsed,
|
|
75
|
+
};
|
|
76
|
+
notifySubscribers(liveEvent);
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore non-JSON
|
|
79
|
+
}
|
|
80
|
+
}) as EventListener);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dispatchEvent(data: string) {
|
|
84
|
+
try {
|
|
85
|
+
const event: LiveEvent = JSON.parse(data);
|
|
86
|
+
if (event.type && event.resource) {
|
|
87
|
+
notifySubscribers(event);
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// ignore non-JSON
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function notifySubscribers(event: LiveEvent) {
|
|
95
|
+
const callbacks = subscribers.get(event.resource);
|
|
96
|
+
if (callbacks) {
|
|
97
|
+
for (const cb of callbacks) cb(event);
|
|
98
|
+
}
|
|
99
|
+
// Wildcard subscribers
|
|
100
|
+
const wildcardCallbacks = subscribers.get('*');
|
|
101
|
+
if (wildcardCallbacks) {
|
|
102
|
+
for (const cb of wildcardCallbacks) cb(event);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function disconnect() {
|
|
107
|
+
if (eventSource) {
|
|
108
|
+
eventSource.close();
|
|
109
|
+
eventSource = null;
|
|
110
|
+
}
|
|
111
|
+
status = 'disconnected';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
subscribe({ resource, callback }) {
|
|
116
|
+
if (!subscribers.has(resource)) {
|
|
117
|
+
subscribers.set(resource, new Set());
|
|
118
|
+
}
|
|
119
|
+
subscribers.get(resource)!.add(callback);
|
|
120
|
+
|
|
121
|
+
// Connect if not already
|
|
122
|
+
if (!eventSource && typeof EventSource !== 'undefined') {
|
|
123
|
+
connect();
|
|
124
|
+
}
|
|
125
|
+
// Add named listener for this resource if connected
|
|
126
|
+
if (eventSource && resource !== '*') {
|
|
127
|
+
addNamedListener(resource);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
const callbacks = subscribers.get(resource);
|
|
132
|
+
if (callbacks) {
|
|
133
|
+
callbacks.delete(callback);
|
|
134
|
+
if (callbacks.size === 0) subscribers.delete(resource);
|
|
135
|
+
}
|
|
136
|
+
if (subscribers.size === 0) disconnect();
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
disconnect,
|
|
141
|
+
|
|
142
|
+
getStatus() {
|
|
143
|
+
return status;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket LiveProvider
|
|
3
|
+
*
|
|
4
|
+
* Connects to a WebSocket server and dispatches LiveEvents to subscribers.
|
|
5
|
+
* The server is expected to send JSON messages matching the LiveEvent shape:
|
|
6
|
+
* { type: 'INSERT' | 'UPDATE' | 'DELETE', resource: string, payload: {...} }
|
|
7
|
+
*/
|
|
8
|
+
import type { LiveProvider, LiveEvent } from './live';
|
|
9
|
+
|
|
10
|
+
export interface WebSocketLiveProviderOptions {
|
|
11
|
+
/** WebSocket server URL, e.g. "wss://api.example.com/ws" */
|
|
12
|
+
url: string;
|
|
13
|
+
/** Reconnect delay in ms (default 3000) */
|
|
14
|
+
reconnectDelay?: number;
|
|
15
|
+
/** Max reconnect attempts (default Infinity) */
|
|
16
|
+
maxReconnects?: number;
|
|
17
|
+
/** Optional callback when connection opens */
|
|
18
|
+
onOpen?: () => void;
|
|
19
|
+
/** Optional callback when connection closes */
|
|
20
|
+
onClose?: (event: CloseEvent) => void;
|
|
21
|
+
/** Optional callback for connection errors */
|
|
22
|
+
onError?: (event: Event) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createWebSocketLiveProvider(options: WebSocketLiveProviderOptions): LiveProvider & { disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected' } {
|
|
26
|
+
const { url, reconnectDelay = 3000, maxReconnects = Infinity } = options;
|
|
27
|
+
|
|
28
|
+
type Callback = (event: LiveEvent) => void;
|
|
29
|
+
const subscribers = new Map<string, Set<Callback>>();
|
|
30
|
+
let ws: WebSocket | null = null;
|
|
31
|
+
let reconnectAttempts = 0;
|
|
32
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
33
|
+
let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
|
|
34
|
+
let intentionalClose = false;
|
|
35
|
+
|
|
36
|
+
function connect() {
|
|
37
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
|
38
|
+
|
|
39
|
+
status = 'connecting';
|
|
40
|
+
intentionalClose = false;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
ws = new WebSocket(url);
|
|
44
|
+
} catch {
|
|
45
|
+
status = 'disconnected';
|
|
46
|
+
scheduleReconnect();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ws.onopen = () => {
|
|
51
|
+
status = 'connected';
|
|
52
|
+
reconnectAttempts = 0;
|
|
53
|
+
options.onOpen?.();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
ws.onmessage = (msgEvent) => {
|
|
57
|
+
try {
|
|
58
|
+
const event: LiveEvent = JSON.parse(msgEvent.data);
|
|
59
|
+
if (event.type && event.resource) {
|
|
60
|
+
const callbacks = subscribers.get(event.resource);
|
|
61
|
+
if (callbacks) {
|
|
62
|
+
for (const cb of callbacks) cb(event);
|
|
63
|
+
}
|
|
64
|
+
// Also notify wildcard '*' subscribers
|
|
65
|
+
const wildcardCallbacks = subscribers.get('*');
|
|
66
|
+
if (wildcardCallbacks) {
|
|
67
|
+
for (const cb of wildcardCallbacks) cb(event);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore non-JSON messages
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
ws.onclose = (event) => {
|
|
76
|
+
status = 'disconnected';
|
|
77
|
+
options.onClose?.(event);
|
|
78
|
+
if (!intentionalClose) scheduleReconnect();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
ws.onerror = (event) => {
|
|
82
|
+
options.onError?.(event);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scheduleReconnect() {
|
|
87
|
+
if (reconnectAttempts >= maxReconnects) return;
|
|
88
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
89
|
+
|
|
90
|
+
reconnectAttempts++;
|
|
91
|
+
reconnectTimer = setTimeout(connect, reconnectDelay);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function disconnect() {
|
|
95
|
+
intentionalClose = true;
|
|
96
|
+
if (reconnectTimer) {
|
|
97
|
+
clearTimeout(reconnectTimer);
|
|
98
|
+
reconnectTimer = null;
|
|
99
|
+
}
|
|
100
|
+
if (ws) {
|
|
101
|
+
ws.close();
|
|
102
|
+
ws = null;
|
|
103
|
+
}
|
|
104
|
+
status = 'disconnected';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Auto-connect on creation
|
|
108
|
+
if (typeof WebSocket !== 'undefined') {
|
|
109
|
+
connect();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
subscribe({ resource, callback }) {
|
|
114
|
+
if (!subscribers.has(resource)) {
|
|
115
|
+
subscribers.set(resource, new Set());
|
|
116
|
+
}
|
|
117
|
+
subscribers.get(resource)!.add(callback);
|
|
118
|
+
|
|
119
|
+
// Ensure connection is open
|
|
120
|
+
if (status === 'disconnected' && typeof WebSocket !== 'undefined') {
|
|
121
|
+
connect();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
const callbacks = subscribers.get(resource);
|
|
126
|
+
if (callbacks) {
|
|
127
|
+
callbacks.delete(callback);
|
|
128
|
+
if (callbacks.size === 0) subscribers.delete(resource);
|
|
129
|
+
}
|
|
130
|
+
// Auto-disconnect if no subscribers left
|
|
131
|
+
if (subscribers.size === 0) disconnect();
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
disconnect,
|
|
136
|
+
|
|
137
|
+
getStatus() {
|
|
138
|
+
return status;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|