thatcher 1.0.46 → 1.0.48
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/README.md +15 -15
- package/package.json +1 -1
- package/src/cli.js +10 -14
- package/src/config/config-loader.js +1 -1
- package/src/index.js +1 -1
- package/src/lib/busybase/store.js +9 -5
- package/src/lib/crud-handlers.js +3 -3
- package/src/server/server.js +42 -8
- package/src/services/permission.service.js +2 -2
- package/src/lib/compression.js +0 -44
- package/src/lib/route-resolver.js +0 -160
- package/src/lib/state-protocol.js +0 -184
- package/src/lib/state-transport-client.js +0 -178
- package/src/lib/state-transport-reconnect.js +0 -126
- package/src/lib/state-transport-server.js +0 -189
- package/src/lib/static-server.js +0 -97
package/README.md
CHANGED
|
@@ -194,7 +194,7 @@ Each entity derives from config:
|
|
|
194
194
|
| Config Key | Purpose |
|
|
195
195
|
|-----------|---------|
|
|
196
196
|
| `fields` | Column definitions (type, required, ref, enum, etc.) |
|
|
197
|
-
| `permission_template` | Maps roles
|
|
197
|
+
| `permission_template` | Maps roles -> allowed actions |
|
|
198
198
|
| `workflow` | State machine name for lifecycle |
|
|
199
199
|
| `row_access` | Scoping: `team`, `assigned`, `client` |
|
|
200
200
|
| `list.defaultSort` | Default list ordering |
|
|
@@ -342,30 +342,30 @@ Use via `@/lib/email-sender` (included).
|
|
|
342
342
|
All APIs accessible via thatcher instance:
|
|
343
343
|
|
|
344
344
|
### CRUD
|
|
345
|
-
- `thatcher.list(entity, where, opts)`
|
|
346
|
-
- `thatcher.get(entity, id)`
|
|
347
|
-
- `thatcher.create(entity, data, user)`
|
|
348
|
-
- `thatcher.update(entity, id, data, user)`
|
|
349
|
-
- `thatcher.delete(entity, id)`
|
|
345
|
+
- `thatcher.list(entity, where, opts)` -> Array
|
|
346
|
+
- `thatcher.get(entity, id)` -> object
|
|
347
|
+
- `thatcher.create(entity, data, user)` -> object
|
|
348
|
+
- `thatcher.update(entity, id, data, user)` -> object
|
|
349
|
+
- `thatcher.delete(entity, id)` -> void
|
|
350
350
|
|
|
351
351
|
### Search
|
|
352
|
-
- `thatcher.search(entity, query, where, opts)`
|
|
352
|
+
- `thatcher.search(entity, query, where, opts)` -> Array (uses FTS)
|
|
353
353
|
|
|
354
354
|
### Workflow
|
|
355
|
-
- `thatcher.transition(entityType, entityId, workflowName, toState, user, reason)`
|
|
356
|
-
- `thatcher.getAvailableTransitions(workflowName, currentState, user, record)`
|
|
355
|
+
- `thatcher.transition(entityType, entityId, workflowName, toState, user, reason)` -> object
|
|
356
|
+
- `thatcher.getAvailableTransitions(workflowName, currentState, user, record)` -> Array
|
|
357
357
|
|
|
358
358
|
### AuthZ
|
|
359
|
-
- `thatcher.can(user, spec, action)`
|
|
360
|
-
- `thatcher.requirePermission(user, spec, action)`
|
|
359
|
+
- `thatcher.can(user, spec, action)` -> boolean
|
|
360
|
+
- `thatcher.requirePermission(user, spec, action)` -> throws if denied
|
|
361
361
|
|
|
362
362
|
### Config
|
|
363
|
-
- `thatcher.getConfigEngine()`
|
|
364
|
-
- `thatcher.getEntitySpec(entityName)`
|
|
365
|
-
- `thatcher.getAllEntities()`
|
|
363
|
+
- `thatcher.getConfigEngine()` -> ConfigGeneratorEngine
|
|
364
|
+
- `thatcher.getEntitySpec(entityName)` -> spec object
|
|
365
|
+
- `thatcher.getAllEntities()` -> Array<string>
|
|
366
366
|
|
|
367
367
|
### Direct DB
|
|
368
|
-
- `thatcher.withTransaction(cb)`
|
|
368
|
+
- `thatcher.withTransaction(cb)` -> Promise<result>
|
|
369
369
|
|
|
370
370
|
## Environment Variables
|
|
371
371
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -100,20 +100,16 @@ async function startRepl() {
|
|
|
100
100
|
const thatcher = new Thatcher({});
|
|
101
101
|
await thatcher.init();
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
thatcher.transition(entityType, entityId, wf, toState, user, reason),
|
|
114
|
-
config: thatcher.config,
|
|
115
|
-
entities: thatcher.getAllEntities(),
|
|
116
|
-
};
|
|
103
|
+
const create = async (entity, data, user) => thatcher.create(entity, data, user);
|
|
104
|
+
const get = async (entity, id) => thatcher.get(entity, id);
|
|
105
|
+
const list = async (entity, where) => thatcher.list(entity, where);
|
|
106
|
+
const update = async (entity, id, data, user) => thatcher.update(entity, id, data, user);
|
|
107
|
+
const remove = async (entity, id) => thatcher.delete(entity, id);
|
|
108
|
+
const search = async (entity, q, where) => thatcher.search(entity, q, where);
|
|
109
|
+
const transition = async (entityType, entityId, wf, toState, user, reason) =>
|
|
110
|
+
thatcher.transition(entityType, entityId, wf, toState, user, reason);
|
|
111
|
+
const config = thatcher.config;
|
|
112
|
+
const entities = thatcher.getAllEntities();
|
|
117
113
|
|
|
118
114
|
rl.prompt();
|
|
119
115
|
|
|
@@ -14,7 +14,7 @@ export async function loadConfig(configSource) {
|
|
|
14
14
|
}
|
|
15
15
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
16
16
|
config = yamlLoad(content);
|
|
17
|
-
} else if (typeof configSource === 'object') {
|
|
17
|
+
} else if (typeof configSource === 'object' && configSource !== null) {
|
|
18
18
|
config = configSource;
|
|
19
19
|
} else {
|
|
20
20
|
throw new Error('Config must be a file path or object');
|
package/src/index.js
CHANGED
|
@@ -84,7 +84,7 @@ export class Thatcher {
|
|
|
84
84
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
85
85
|
const { load: yamlLoad } = await import('js-yaml');
|
|
86
86
|
masterConfig = yamlLoad(content);
|
|
87
|
-
} else if (typeof config === 'object') {
|
|
87
|
+
} else if (typeof config === 'object' && config !== null) {
|
|
88
88
|
masterConfig = config;
|
|
89
89
|
} else {
|
|
90
90
|
// Auto-discover
|
|
@@ -203,13 +203,17 @@ export async function count(entity, where = {}, options = {}) {
|
|
|
203
203
|
const tbl = tableName(entity);
|
|
204
204
|
let rows = unwrap(await applyWhere(client().from(tbl).select('*'), where), 'count');
|
|
205
205
|
rows = applyVisibility(spec, rows, where, options);
|
|
206
|
+
if (options.user && (spec.rowAccess || spec.row_access)) {
|
|
207
|
+
const { permissionService } = await import('../services/permission.service.js');
|
|
208
|
+
rows = permissionService.filterRecords(options.user, spec, rows);
|
|
209
|
+
}
|
|
206
210
|
return rows.length;
|
|
207
211
|
}
|
|
208
212
|
|
|
209
|
-
export async function listWithPagination(entity, where = {}, page = 1, pageSize = 50) {
|
|
213
|
+
export async function listWithPagination(entity, where = {}, page = 1, pageSize = 50, options = {}) {
|
|
210
214
|
const finalPage = Math.max(1, page);
|
|
211
|
-
const total = await count(entity, where);
|
|
212
|
-
const items = await list(entity, where, { offset: (finalPage - 1) * pageSize, limit: pageSize });
|
|
215
|
+
const total = await count(entity, where, options);
|
|
216
|
+
const items = await list(entity, where, { ...options, offset: (finalPage - 1) * pageSize, limit: pageSize });
|
|
213
217
|
return { items, pagination: { page: finalPage, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
|
|
214
218
|
}
|
|
215
219
|
|
|
@@ -337,11 +341,11 @@ export async function search(entity, query, where = {}, options = {}) {
|
|
|
337
341
|
return matched;
|
|
338
342
|
}
|
|
339
343
|
|
|
340
|
-
export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null) {
|
|
344
|
+
export async function searchWithPagination(entity, query, where = {}, page = 1, pageSize = null, options = {}) {
|
|
341
345
|
const spec = specOf(entity);
|
|
342
346
|
const finalPageSize = pageSize || spec.list?.pageSize || 50;
|
|
343
347
|
const finalPage = Math.max(1, page);
|
|
344
|
-
const all = await search(entity, query, where);
|
|
348
|
+
const all = await search(entity, query, where, options);
|
|
345
349
|
const total = all.length;
|
|
346
350
|
const items = all.slice((finalPage - 1) * finalPageSize, finalPage * finalPageSize);
|
|
347
351
|
return { items, pagination: { page: finalPage, pageSize: finalPageSize, total, totalPages: Math.ceil(total / finalPageSize) } };
|
package/src/lib/crud-handlers.js
CHANGED
|
@@ -43,7 +43,7 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
43
43
|
let items, pagination;
|
|
44
44
|
|
|
45
45
|
if (q) {
|
|
46
|
-
const result = await searchWithPagination(entityName, q, {}, finalPage, finalPageSize);
|
|
46
|
+
const result = await searchWithPagination(entityName, q, {}, finalPage, finalPageSize, { user });
|
|
47
47
|
items = result.items;
|
|
48
48
|
pagination = result.pagination;
|
|
49
49
|
} else {
|
|
@@ -54,7 +54,7 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
54
54
|
coercedFilters[key] = fd ? coerceFieldValue(value, fd.type) : value;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
const result = await listWithPagination(entityName, coercedFilters, finalPage, finalPageSize);
|
|
57
|
+
const result = await listWithPagination(entityName, coercedFilters, finalPage, finalPageSize, { user });
|
|
58
58
|
items = result.items;
|
|
59
59
|
pagination = result.pagination;
|
|
60
60
|
}
|
|
@@ -200,7 +200,7 @@ export function createCrudHandlers(entityName, spec) {
|
|
|
200
200
|
id,
|
|
201
201
|
data: { id, uploaded_files: files },
|
|
202
202
|
user,
|
|
203
|
-
});
|
|
203
|
+
}).catch(e => log.error(e.message));
|
|
204
204
|
return ok({ id, uploaded_files: files });
|
|
205
205
|
}
|
|
206
206
|
|
package/src/server/server.js
CHANGED
|
@@ -3,6 +3,31 @@ import path from 'path';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { createLogger } from '../lib/logger.js';
|
|
6
|
+
import { getLucia } from '../engine.server.js';
|
|
7
|
+
import { requirePermission } from '../lib/auth-middleware.js';
|
|
8
|
+
|
|
9
|
+
// Request-scoped session resolution: NOT engine.server.js's getUser(), which
|
|
10
|
+
// reads a module-level _currentRequest global -- unsafe here since this raw
|
|
11
|
+
// http server handles requests concurrently and that global would let one
|
|
12
|
+
// in-flight request's cookie leak into another's session lookup mid-await.
|
|
13
|
+
// getLucia() itself holds no per-request state, so calling it directly per
|
|
14
|
+
// request and validating against this request's own cookie header is safe.
|
|
15
|
+
async function resolveRequestUser(req) {
|
|
16
|
+
let lucia;
|
|
17
|
+
try { lucia = getLucia(); } catch { return null; }
|
|
18
|
+
const cookieHeader = req.headers?.cookie || '';
|
|
19
|
+
if (!cookieHeader) return null;
|
|
20
|
+
const cookieName = lucia.sessionCookieName || 'thatcher_session';
|
|
21
|
+
const match = cookieHeader.split(';').find(c => c.trim().startsWith(cookieName + '='));
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
const sessionId = decodeURIComponent(match.split('=')[1] || '');
|
|
24
|
+
if (!sessionId) return null;
|
|
25
|
+
try {
|
|
26
|
+
const { user, session } = await lucia.validateSession(sessionId);
|
|
27
|
+
if (!user || !session) return null;
|
|
28
|
+
return user;
|
|
29
|
+
} catch { return null; }
|
|
30
|
+
}
|
|
6
31
|
|
|
7
32
|
const log = createLogger('[Server]');
|
|
8
33
|
const pageLog = createLogger('[Page]');
|
|
@@ -203,11 +228,12 @@ async function sendResponse(res, response) {
|
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
async function handleGenericCrud(req, res, entity, id, action, thatcher, configEngineArg) {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
231
|
+
const user = await resolveRequestUser(req);
|
|
232
|
+
if (!user) {
|
|
233
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
234
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
211
237
|
|
|
212
238
|
// Prefer the engine passed down from createServer options (guaranteed the
|
|
213
239
|
// same instance that was initialized at startup); fall back to thatcher /
|
|
@@ -217,15 +243,23 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
217
243
|
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
218
244
|
configEngine = getConfigEngineSync();
|
|
219
245
|
}
|
|
246
|
+
let spec;
|
|
220
247
|
try {
|
|
221
|
-
configEngine.generateEntitySpec(entity);
|
|
248
|
+
spec = configEngine.generateEntitySpec(entity);
|
|
222
249
|
} catch (e) {
|
|
223
250
|
res.writeHead(404);
|
|
224
251
|
res.end(JSON.stringify({ error: `Entity '${entity}' not found` }));
|
|
225
252
|
return;
|
|
226
253
|
}
|
|
227
254
|
|
|
228
|
-
|
|
255
|
+
const methodAction = { GET: 'list', POST: 'create', PUT: 'edit', PATCH: 'edit', DELETE: 'delete' }[req.method] || 'view';
|
|
256
|
+
try {
|
|
257
|
+
await requirePermission(user, spec, id && req.method === 'GET' ? 'view' : methodAction);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
res.writeHead(e?.status || 403, { 'Content-Type': 'application/json' });
|
|
260
|
+
res.end(JSON.stringify({ error: e?.message || 'Forbidden' }));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
229
263
|
|
|
230
264
|
let body;
|
|
231
265
|
try {
|
|
@@ -250,7 +284,7 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
250
284
|
return;
|
|
251
285
|
}
|
|
252
286
|
} else {
|
|
253
|
-
result = await thatcher.list(entity);
|
|
287
|
+
result = await thatcher.list(entity, {}, { user });
|
|
254
288
|
}
|
|
255
289
|
break;
|
|
256
290
|
|
|
@@ -47,12 +47,12 @@ class PermissionService {
|
|
|
47
47
|
return false;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
if (scope === 'assigned' &&
|
|
50
|
+
if (scope === 'assigned' && user.role !== partnerRole && record[ownerField] !== user.id) {
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
if (scope === 'assigned_or_team' && user.role !== partnerRole) {
|
|
55
|
-
const assignedMatch = record[ownerField]
|
|
55
|
+
const assignedMatch = record[ownerField] === user.id;
|
|
56
56
|
const teamMatch = record.team_id && user.team_id && record.team_id === user.team_id;
|
|
57
57
|
if (!assignedMatch && !teamMatch) return false;
|
|
58
58
|
}
|
package/src/lib/compression.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import zlib from 'zlib';
|
|
2
|
-
|
|
3
|
-
const COMPRESSION_THRESHOLD = 1024; // Only compress files >1KB
|
|
4
|
-
|
|
5
|
-
export function compress(content, acceptEncoding = '') {
|
|
6
|
-
const size = Buffer.byteLength(content, 'utf-8');
|
|
7
|
-
if (size < COMPRESSION_THRESHOLD) return { content, encoding: null };
|
|
8
|
-
|
|
9
|
-
const ae = acceptEncoding.toLowerCase();
|
|
10
|
-
|
|
11
|
-
if (ae.includes('br')) {
|
|
12
|
-
const compressed = zlib.brotliCompressSync(content, {
|
|
13
|
-
params: {
|
|
14
|
-
[zlib.constants.BROTLI_PARAM_QUALITY]: 6,
|
|
15
|
-
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: size
|
|
16
|
-
}
|
|
17
|
-
});
|
|
18
|
-
return { content: compressed, encoding: 'br' };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (ae.includes('gzip')) {
|
|
22
|
-
const compressed = zlib.gzipSync(content, { level: 6 });
|
|
23
|
-
return { content: compressed, encoding: 'gzip' };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return { content, encoding: null };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function getCacheHeaders(type, maxAge = 86400) {
|
|
30
|
-
if (type === 'static') {
|
|
31
|
-
return {
|
|
32
|
-
'Cache-Control': `public, max-age=${maxAge}, immutable`,
|
|
33
|
-
'Expires': new Date(Date.now() + maxAge * 1000).toUTCString()
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
if (type === 'dynamic') {
|
|
37
|
-
return {
|
|
38
|
-
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
|
39
|
-
'Pragma': 'no-cache',
|
|
40
|
-
'Expires': '0'
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
return {};
|
|
44
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
|
|
4
|
-
export function resolveParamRoute(baseDir, segments) {
|
|
5
|
-
if (!fs.existsSync(baseDir)) return null;
|
|
6
|
-
if (segments.length === 0) {
|
|
7
|
-
const r = path.join(baseDir, 'route.js');
|
|
8
|
-
return fs.existsSync(r) ? r : null;
|
|
9
|
-
}
|
|
10
|
-
const [seg, ...rest] = segments;
|
|
11
|
-
const entries = fs.readdirSync(baseDir, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
12
|
-
for (const entry of entries) {
|
|
13
|
-
const name = entry.name;
|
|
14
|
-
if (name === seg || name.startsWith('[')) {
|
|
15
|
-
const found = resolveParamRoute(path.join(baseDir, name), rest);
|
|
16
|
-
if (found) return found;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function resolveSpecificParams(routeFile, pathParts) {
|
|
23
|
-
const result = {};
|
|
24
|
-
const routeRelative = routeFile.replace(/.*src\/app\/api\//, '').replace(/\/route\.js$/, '');
|
|
25
|
-
const routeSegments = routeRelative.split('/');
|
|
26
|
-
const urlSegments = pathParts.slice(0);
|
|
27
|
-
for (let i = 0; i < routeSegments.length && i < urlSegments.length; i++) {
|
|
28
|
-
const seg = routeSegments[i];
|
|
29
|
-
if (seg.startsWith('[') && seg.endsWith(']')) {
|
|
30
|
-
const paramName = seg.replace(/^\[\.\.\./, '').replace(/[[\]]/g, '');
|
|
31
|
-
result[paramName] = urlSegments[i];
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return result;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function singularize(name) {
|
|
38
|
-
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
|
|
39
|
-
if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) return name.slice(0, -2);
|
|
40
|
-
if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
|
|
41
|
-
return name;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function buildNestedRoutePath(baseDir, domain, parentEntity, childParts) {
|
|
45
|
-
if (childParts.length === 0) return null;
|
|
46
|
-
|
|
47
|
-
function buildSegments(parentParam, childParamFn) {
|
|
48
|
-
const segs = [domain, parentEntity, parentParam];
|
|
49
|
-
for (let i = 0; i < childParts.length; i++) {
|
|
50
|
-
if (i % 2 === 0) segs.push(childParts[i]);
|
|
51
|
-
else segs.push(childParamFn(i));
|
|
52
|
-
}
|
|
53
|
-
return path.join(baseDir, 'src/app/api', ...segs, 'route.js');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const variants = [
|
|
57
|
-
() => buildSegments('[id]', (i) => `[${childParts[i - 1]}Id]`),
|
|
58
|
-
() => buildSegments('[id]', (i) => `[${singularize(childParts[i - 1])}Id]`),
|
|
59
|
-
() => buildSegments(`[${parentEntity}Id]`, (i) => `[${childParts[i - 1]}Id]`),
|
|
60
|
-
() => buildSegments(`[${parentEntity}Id]`, (i) => `[${singularize(childParts[i - 1])}Id]`),
|
|
61
|
-
() => buildSegments('[id]', () => `[${childParts[0]}Id]`),
|
|
62
|
-
() => buildSegments('[id]', () => `[${singularize(childParts[0])}Id]`),
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
// Also try a "leaf-action" shape with no further id after the child segment.
|
|
66
|
-
// Routes like /mwr/review/[id]/export-pdf/route.js (parent + id + leaf action)
|
|
67
|
-
// were missed by the segment-pair variants above.
|
|
68
|
-
if (childParts.length === 1) {
|
|
69
|
-
const leaf = path.join(baseDir, 'src/app/api', domain, parentEntity, '[id]', childParts[0], 'route.js');
|
|
70
|
-
if (fs.existsSync(leaf)) return leaf;
|
|
71
|
-
const leafByEntity = path.join(baseDir, 'src/app/api', domain, parentEntity, `[${parentEntity}Id]`, childParts[0], 'route.js');
|
|
72
|
-
if (fs.existsSync(leafByEntity)) return leafByEntity;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
for (const variant of variants) {
|
|
76
|
-
const candidate = variant();
|
|
77
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
78
|
-
}
|
|
79
|
-
return variants[0]();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const DOMAINS = ['friday', 'mwr'];
|
|
83
|
-
|
|
84
|
-
export function resolveRoute(__dirname, pathname, url) {
|
|
85
|
-
const pathParts = pathname.slice(5).split('/').filter(Boolean);
|
|
86
|
-
|
|
87
|
-
// Reject any '..' or '.' segment before it can reach a filesystem path
|
|
88
|
-
// join or a dynamic import -- otherwise a crafted URL can traverse out of
|
|
89
|
-
// src/app/api into arbitrary files on disk.
|
|
90
|
-
if (pathParts.some(seg => seg === '..' || seg === '.')) {
|
|
91
|
-
return { routeFile: null, params: {}, isDomain: false, firstPart: pathParts[0], pathParts };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const firstPart = pathParts[0];
|
|
95
|
-
const isDomain = DOMAINS.includes(firstPart);
|
|
96
|
-
let routeFile = null;
|
|
97
|
-
let params = {};
|
|
98
|
-
|
|
99
|
-
if (isDomain) {
|
|
100
|
-
const domain = firstPart;
|
|
101
|
-
const domainParts = pathParts.slice(1);
|
|
102
|
-
|
|
103
|
-
const specificCheck = path.join(__dirname, `src/app/api/${domain}/${domainParts.join('/')}/route.js`);
|
|
104
|
-
if (fs.existsSync(specificCheck)) {
|
|
105
|
-
routeFile = specificCheck;
|
|
106
|
-
params = resolveSpecificParams(specificCheck, pathParts);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (!routeFile && domainParts.length >= 3) {
|
|
110
|
-
const parentEntity = domainParts[0];
|
|
111
|
-
const childParts = domainParts.slice(2);
|
|
112
|
-
const parentId = domainParts[1];
|
|
113
|
-
const childEntity = childParts[0];
|
|
114
|
-
const childId = childParts[1] || null;
|
|
115
|
-
|
|
116
|
-
const nestedSpecific = buildNestedRoutePath(__dirname, domain, parentEntity, childParts);
|
|
117
|
-
if (nestedSpecific && fs.existsSync(nestedSpecific)) {
|
|
118
|
-
routeFile = nestedSpecific;
|
|
119
|
-
params = resolveSpecificParams(nestedSpecific, pathParts);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (!routeFile) {
|
|
123
|
-
routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
|
|
124
|
-
url.searchParams.set('domain', domain);
|
|
125
|
-
params = { entity: childEntity, path: childId ? [childId] : [], parentEntity, parentId };
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (!routeFile && domainParts.length >= 1) {
|
|
130
|
-
const entity = domainParts[0];
|
|
131
|
-
const entityPath = domainParts.slice(1);
|
|
132
|
-
routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
|
|
133
|
-
url.searchParams.set('domain', domain);
|
|
134
|
-
params = { entity, path: entityPath };
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!routeFile && firstPart) {
|
|
139
|
-
const exactRoute = path.join(__dirname, `src/app/api/${pathParts.join('/')}/route.js`);
|
|
140
|
-
if (fs.existsSync(exactRoute)) {
|
|
141
|
-
routeFile = exactRoute;
|
|
142
|
-
params = resolveSpecificParams(exactRoute, pathParts);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (!routeFile && firstPart) {
|
|
147
|
-
const paramRouteFile = resolveParamRoute(path.join(__dirname, 'src/app/api', firstPart), pathParts.slice(1));
|
|
148
|
-
if (paramRouteFile) {
|
|
149
|
-
routeFile = paramRouteFile;
|
|
150
|
-
params = resolveSpecificParams(paramRouteFile, pathParts);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (!routeFile) {
|
|
155
|
-
routeFile = path.join(__dirname, 'src/app/api/[entity]/[[...path]]/route.js');
|
|
156
|
-
params = { entity: firstPart, path: pathParts.slice(1) };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return { routeFile, params, isDomain, firstPart, pathParts };
|
|
160
|
-
}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
// state-sync channel: message-type vocabulary + VectorClock + schema validation
|
|
2
|
-
// shared by state-transport-server.js / state-transport-client.js /
|
|
3
|
-
// state-transport-reconnect.js. This is a structured, reconnect-aware
|
|
4
|
-
// WebSocket protocol (real `ws` transport, IP/rate-limited via
|
|
5
|
-
// connection-guard.js, exponential-backoff client reconnect with a polling
|
|
6
|
-
// fallback) -- distinct from the simpler in-process pub/sub in
|
|
7
|
-
// realtime-server.js (the "ws-broadcast" channel actually wired into the CRUD
|
|
8
|
-
// write path via src/lib/api.js). As of this writing, grepping the whole repo
|
|
9
|
-
// found ZERO importers of this quartet outside their own internal cross-imports
|
|
10
|
-
// (state-transport-client.js imports state-transport-reconnect.js; both import
|
|
11
|
-
// this file) -- nothing external constructs a StateTransportServer/Client, so
|
|
12
|
-
// this stack is currently unwired/dormant rather than superseding
|
|
13
|
-
// realtime-server.js. Keep both; re-check importers before deleting either.
|
|
14
|
-
const MESSAGE_TYPES = {
|
|
15
|
-
STATE_UPDATE: 'state_update',
|
|
16
|
-
STATE_SNAPSHOT: 'state_snapshot',
|
|
17
|
-
STATE_REQUEST: 'state_request',
|
|
18
|
-
STATE_ACK: 'state_ack',
|
|
19
|
-
STATE_NACK: 'state_nack',
|
|
20
|
-
PING: 'ping',
|
|
21
|
-
PONG: 'pong'
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const ERROR_CODES = {
|
|
25
|
-
INVALID_MESSAGE: 'invalid_message',
|
|
26
|
-
INVALID_VERSION: 'invalid_version',
|
|
27
|
-
CONFLICT_DETECTED: 'conflict_detected',
|
|
28
|
-
RATE_LIMITED: 'rate_limited',
|
|
29
|
-
VALIDATION_FAILED: 'validation_failed'
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
class VectorClock {
|
|
33
|
-
constructor(nodeId) {
|
|
34
|
-
this.nodeId = nodeId
|
|
35
|
-
this.clock = { [nodeId]: 0 }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
increment() {
|
|
39
|
-
this.clock[this.nodeId] = (this.clock[this.nodeId] || 0) + 1
|
|
40
|
-
return this.clock[this.nodeId]
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
update(otherClock) {
|
|
44
|
-
for (const [nodeId, timestamp] of Object.entries(otherClock)) {
|
|
45
|
-
this.clock[nodeId] = Math.max(this.clock[nodeId] || 0, timestamp)
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
compare(otherClock) {
|
|
50
|
-
const keys = new Set([...Object.keys(this.clock), ...Object.keys(otherClock)])
|
|
51
|
-
let hasGreater = false
|
|
52
|
-
let hasLess = false
|
|
53
|
-
|
|
54
|
-
for (const key of keys) {
|
|
55
|
-
const mine = this.clock[key] || 0
|
|
56
|
-
const theirs = otherClock[key] || 0
|
|
57
|
-
if (mine > theirs) hasGreater = true
|
|
58
|
-
if (mine < theirs) hasLess = true
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (hasGreater && !hasLess) return 1
|
|
62
|
-
if (hasLess && !hasGreater) return -1
|
|
63
|
-
if (!hasGreater && !hasLess) return 0
|
|
64
|
-
return null // Concurrent
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
serialize() {
|
|
68
|
-
return { ...this.clock }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
static deserialize(data, nodeId) {
|
|
72
|
-
const vc = new VectorClock(nodeId)
|
|
73
|
-
vc.clock = { ...data }
|
|
74
|
-
return vc
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const MessageSchema = {
|
|
79
|
-
validate(message) {
|
|
80
|
-
if (!message || typeof message !== 'object') {
|
|
81
|
-
return { valid: false, error: 'Message must be an object' }
|
|
82
|
-
}
|
|
83
|
-
const validTypes = Object.values(MESSAGE_TYPES)
|
|
84
|
-
if (!validTypes.includes(message.type)) {
|
|
85
|
-
return { valid: false, error: 'Invalid message type' }
|
|
86
|
-
}
|
|
87
|
-
if (message.type !== MESSAGE_TYPES.PING && message.type !== MESSAGE_TYPES.PONG) {
|
|
88
|
-
if (!message.version || typeof message.version !== 'object') {
|
|
89
|
-
return { valid: false, error: 'Missing or invalid version vector' }
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (message.type === MESSAGE_TYPES.STATE_UPDATE || message.type === MESSAGE_TYPES.STATE_SNAPSHOT) {
|
|
93
|
-
if (message.data === undefined) {
|
|
94
|
-
return { valid: false, error: 'Missing data field' }
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return { valid: true }
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function createMessage(type, payload = {}) {
|
|
102
|
-
return {
|
|
103
|
-
type,
|
|
104
|
-
timestamp: Date.now(),
|
|
105
|
-
...payload
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function createStateUpdate(vectorClock, data, operations = []) {
|
|
110
|
-
return createMessage(MESSAGE_TYPES.STATE_UPDATE, {
|
|
111
|
-
version: vectorClock.serialize(),
|
|
112
|
-
data,
|
|
113
|
-
operations
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function createStateSnapshot(vectorClock, data) {
|
|
118
|
-
return createMessage(MESSAGE_TYPES.STATE_SNAPSHOT, {
|
|
119
|
-
version: vectorClock.serialize(),
|
|
120
|
-
data
|
|
121
|
-
})
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function createStateRequest(vectorClock) {
|
|
125
|
-
return createMessage(MESSAGE_TYPES.STATE_REQUEST, {
|
|
126
|
-
version: vectorClock.serialize()
|
|
127
|
-
})
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function createStateAck(messageId, vectorClock) {
|
|
131
|
-
return createMessage(MESSAGE_TYPES.STATE_ACK, {
|
|
132
|
-
messageId,
|
|
133
|
-
version: vectorClock.serialize()
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function createStateNack(messageId, error, errorCode) {
|
|
138
|
-
return createMessage(MESSAGE_TYPES.STATE_NACK, {
|
|
139
|
-
messageId,
|
|
140
|
-
error,
|
|
141
|
-
errorCode
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function createPing() {
|
|
146
|
-
return createMessage(MESSAGE_TYPES.PING)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function createPong(pingTimestamp) {
|
|
150
|
-
return createMessage(MESSAGE_TYPES.PONG, { pingTimestamp })
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const Protocol = {
|
|
154
|
-
MESSAGE_TYPES,
|
|
155
|
-
ERROR_CODES,
|
|
156
|
-
VectorClock,
|
|
157
|
-
MessageSchema,
|
|
158
|
-
createMessage,
|
|
159
|
-
createStateUpdate,
|
|
160
|
-
createStateSnapshot,
|
|
161
|
-
createStateRequest,
|
|
162
|
-
createStateAck,
|
|
163
|
-
createStateNack,
|
|
164
|
-
createPing,
|
|
165
|
-
createPong
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export {
|
|
169
|
-
MESSAGE_TYPES,
|
|
170
|
-
ERROR_CODES,
|
|
171
|
-
VectorClock,
|
|
172
|
-
MessageSchema,
|
|
173
|
-
createMessage,
|
|
174
|
-
createStateUpdate,
|
|
175
|
-
createStateSnapshot,
|
|
176
|
-
createStateRequest,
|
|
177
|
-
createStateAck,
|
|
178
|
-
createStateNack,
|
|
179
|
-
createPing,
|
|
180
|
-
createPong,
|
|
181
|
-
Protocol
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export default Protocol
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
// state-sync channel (client side): browser WebSocket client with
|
|
2
|
-
// exponential-backoff reconnect + polling fallback, part of the
|
|
3
|
-
// state-protocol.js quartet. See the header comment in state-protocol.js --
|
|
4
|
-
// this is a structured, reconnect-aware protocol distinct from
|
|
5
|
-
// realtime-server.js's simple in-process pub/sub. Zero real importers found
|
|
6
|
-
// repo-wide as of this writing; currently dormant/unwired.
|
|
7
|
-
import Protocol from './state-protocol.js'
|
|
8
|
-
import { CONFIG, ReconnectManager } from './state-transport-reconnect.js'
|
|
9
|
-
import { createLogger } from './logger.js'
|
|
10
|
-
|
|
11
|
-
const log = createLogger('[StateTransport]')
|
|
12
|
-
|
|
13
|
-
class StateTransportClient {
|
|
14
|
-
constructor(config = {}) {
|
|
15
|
-
this.config = { ...CONFIG, ...config }
|
|
16
|
-
this.ws = null
|
|
17
|
-
this.state = 'disconnected'
|
|
18
|
-
this.listeners = new Map()
|
|
19
|
-
this.messageQueue = []
|
|
20
|
-
this.connectionTimeoutTimer = null
|
|
21
|
-
|
|
22
|
-
this.reconnect = new ReconnectManager(this.config, {
|
|
23
|
-
onReconnecting: (data) => { this.emit('reconnecting', data); this.connect() },
|
|
24
|
-
onFallback: (msg) => this.emit('fallback', msg),
|
|
25
|
-
onPollStart: () => this.emit('poll_start'),
|
|
26
|
-
onPollSuccess: (result) => this.emit('poll_success', result),
|
|
27
|
-
onPollError: (error) => this.emit('poll_error', error)
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
connect(url) {
|
|
32
|
-
this.url = url || this.url
|
|
33
|
-
if (!this.url) {
|
|
34
|
-
this.emit('error', new Error('No URL provided for connection'))
|
|
35
|
-
return
|
|
36
|
-
}
|
|
37
|
-
this.reconnect.wsUrl = this.url
|
|
38
|
-
|
|
39
|
-
if (this.state === 'connecting' || this.state === 'connected') return
|
|
40
|
-
|
|
41
|
-
this.state = 'connecting'
|
|
42
|
-
this.emit('connecting')
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
this.ws = new WebSocket(this.url)
|
|
46
|
-
|
|
47
|
-
this.connectionTimeoutTimer = setTimeout(() => {
|
|
48
|
-
if (this.state === 'connecting') this.handleConnectionTimeout()
|
|
49
|
-
}, this.config.connectionTimeout)
|
|
50
|
-
|
|
51
|
-
this.ws.onopen = () => this.handleOpen()
|
|
52
|
-
this.ws.onclose = (event) => this.handleClose(event)
|
|
53
|
-
this.ws.onerror = (error) => this.handleError(error)
|
|
54
|
-
this.ws.onmessage = (event) => this.handleMessage(event)
|
|
55
|
-
} catch (error) {
|
|
56
|
-
this.handleConnectionFailure(error)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
handleOpen() {
|
|
61
|
-
clearTimeout(this.connectionTimeoutTimer)
|
|
62
|
-
this.state = 'connected'
|
|
63
|
-
this.reconnect.resetAttempts()
|
|
64
|
-
this.emit('connected')
|
|
65
|
-
this.reconnect.startPingInterval(
|
|
66
|
-
(msg) => this.send(msg),
|
|
67
|
-
() => this.ws.close()
|
|
68
|
-
)
|
|
69
|
-
this.flushMessageQueue()
|
|
70
|
-
this.reconnect.stopPolling()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
handleClose(event) {
|
|
74
|
-
clearTimeout(this.connectionTimeoutTimer)
|
|
75
|
-
this.state = 'disconnected'
|
|
76
|
-
this.reconnect.stopPingInterval()
|
|
77
|
-
this.emit('disconnected', { code: event.code, reason: event.reason })
|
|
78
|
-
this.reconnect.scheduleReconnect()
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
handleError(error) {
|
|
82
|
-
this.emit('error', error)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
handleConnectionTimeout() {
|
|
86
|
-
if (this.ws) this.ws.close()
|
|
87
|
-
this.handleConnectionFailure(new Error('Connection timeout'))
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
handleConnectionFailure(error) {
|
|
91
|
-
this.emit('error', error)
|
|
92
|
-
this.state = 'disconnected'
|
|
93
|
-
|
|
94
|
-
if (this.reconnect.shouldFallback) {
|
|
95
|
-
this.reconnect.activateFallback()
|
|
96
|
-
} else {
|
|
97
|
-
this.reconnect.scheduleReconnect()
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
handleMessage(event) {
|
|
102
|
-
try {
|
|
103
|
-
const message = JSON.parse(event.data)
|
|
104
|
-
if (message.type === Protocol.MESSAGE_TYPES.PONG) {
|
|
105
|
-
this.reconnect.recordPong()
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
this.emit('message', message)
|
|
109
|
-
} catch (error) {
|
|
110
|
-
this.emit('error', { error, phase: 'message_parsing', data: event.data })
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
send(message) {
|
|
115
|
-
if (this.state === 'connected' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
116
|
-
try {
|
|
117
|
-
this.ws.send(JSON.stringify(message))
|
|
118
|
-
return true
|
|
119
|
-
} catch (error) {
|
|
120
|
-
this.emit('error', { error, phase: 'send' })
|
|
121
|
-
this.messageQueue.push(message)
|
|
122
|
-
return false
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
this.messageQueue.push(message)
|
|
126
|
-
return false
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
flushMessageQueue() {
|
|
130
|
-
while (this.messageQueue.length > 0 && this.state === 'connected') {
|
|
131
|
-
const message = this.messageQueue.shift()
|
|
132
|
-
this.send(message)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
disconnect() {
|
|
137
|
-
this.reconnect.cleanup()
|
|
138
|
-
if (this.ws) {
|
|
139
|
-
this.ws.close()
|
|
140
|
-
this.ws = null
|
|
141
|
-
}
|
|
142
|
-
this.state = 'disconnected'
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
on(event, handler) {
|
|
146
|
-
if (!this.listeners.has(event)) this.listeners.set(event, [])
|
|
147
|
-
this.listeners.get(event).push(handler)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
off(event, handler) {
|
|
151
|
-
if (!this.listeners.has(event)) return
|
|
152
|
-
const handlers = this.listeners.get(event)
|
|
153
|
-
const index = handlers.indexOf(handler)
|
|
154
|
-
if (index > -1) handlers.splice(index, 1)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
emit(event, data) {
|
|
158
|
-
if (!this.listeners.has(event)) return
|
|
159
|
-
for (const handler of this.listeners.get(event)) {
|
|
160
|
-
try {
|
|
161
|
-
handler(data)
|
|
162
|
-
} catch (error) {
|
|
163
|
-
log.error(`event handler error for ${event}:`, { message: error?.message || String(error) })
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
getState() {
|
|
169
|
-
return this.state
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
isConnected() {
|
|
173
|
-
return this.state === 'connected'
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export { StateTransportClient }
|
|
178
|
-
export default StateTransportClient
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
// state-sync channel (reconnect logic): exponential-backoff + polling-fallback
|
|
2
|
-
// manager consumed by state-transport-client.js, part of the state-protocol.js
|
|
3
|
-
// quartet. See the header comment in state-protocol.js for the full
|
|
4
|
-
// ws-broadcast vs state-sync distinction. Zero real importers repo-wide
|
|
5
|
-
// outside this quartet's own internal cross-imports as of this writing.
|
|
6
|
-
import Protocol from './state-protocol.js'
|
|
7
|
-
|
|
8
|
-
const CONFIG = {
|
|
9
|
-
reconnectInitialDelay: 1000,
|
|
10
|
-
reconnectMaxDelay: 30000,
|
|
11
|
-
reconnectBackoffFactor: 2,
|
|
12
|
-
pingInterval: 25000,
|
|
13
|
-
connectionTimeout: 10000,
|
|
14
|
-
pollingInterval: 5000,
|
|
15
|
-
pollingFallbackDelay: 3000
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
class ReconnectManager {
|
|
19
|
-
constructor(config, callbacks) {
|
|
20
|
-
this.config = config
|
|
21
|
-
this.callbacks = callbacks
|
|
22
|
-
this.reconnectAttempts = 0
|
|
23
|
-
this.reconnectTimer = null
|
|
24
|
-
this.pingTimer = null
|
|
25
|
-
this.pollingTimer = null
|
|
26
|
-
this.lastPongTime = 0
|
|
27
|
-
this.useFallback = false
|
|
28
|
-
this.wsUrl = null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
get shouldFallback() {
|
|
32
|
-
return this.reconnectAttempts >= 3 && !this.useFallback
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
resetAttempts() {
|
|
36
|
-
this.reconnectAttempts = 0
|
|
37
|
-
this.useFallback = false
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
recordPong() {
|
|
41
|
-
this.lastPongTime = Date.now()
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
scheduleReconnect() {
|
|
45
|
-
if (this.reconnectTimer) {
|
|
46
|
-
clearTimeout(this.reconnectTimer)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const delay = Math.min(
|
|
50
|
-
this.config.reconnectInitialDelay * Math.pow(this.config.reconnectBackoffFactor, this.reconnectAttempts),
|
|
51
|
-
this.config.reconnectMaxDelay
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
const jitter = Math.random() * 1000
|
|
55
|
-
this.reconnectAttempts++
|
|
56
|
-
|
|
57
|
-
this.reconnectTimer = setTimeout(() => {
|
|
58
|
-
this.callbacks.onReconnecting({ attempt: this.reconnectAttempts })
|
|
59
|
-
}, delay + jitter)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
activateFallback() {
|
|
63
|
-
this.useFallback = true
|
|
64
|
-
this.callbacks.onFallback('Switching to polling mode')
|
|
65
|
-
this.startPolling()
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
startPolling() {
|
|
69
|
-
if (this.pollingTimer) return
|
|
70
|
-
|
|
71
|
-
this.pollingTimer = setInterval(async () => {
|
|
72
|
-
try {
|
|
73
|
-
this.callbacks.onPollStart()
|
|
74
|
-
const result = await this.pollState()
|
|
75
|
-
this.callbacks.onPollSuccess(result)
|
|
76
|
-
} catch (error) {
|
|
77
|
-
this.callbacks.onPollError(error)
|
|
78
|
-
}
|
|
79
|
-
}, this.config.pollingInterval)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async pollState() {
|
|
83
|
-
const httpUrl = this.wsUrl.replace('ws://', 'http://').replace('wss://', 'https://')
|
|
84
|
-
const response = await fetch(`${httpUrl}/poll`)
|
|
85
|
-
if (!response.ok) {
|
|
86
|
-
throw new Error(`Polling failed: ${response.status}`)
|
|
87
|
-
}
|
|
88
|
-
return await response.json()
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
startPingInterval(sendFn, closeFn) {
|
|
92
|
-
this.pingTimer = setInterval(() => {
|
|
93
|
-
sendFn(Protocol.createPing())
|
|
94
|
-
const timeSinceLastPong = Date.now() - this.lastPongTime
|
|
95
|
-
if (timeSinceLastPong > this.config.pingInterval * 2) {
|
|
96
|
-
closeFn()
|
|
97
|
-
}
|
|
98
|
-
}, this.config.pingInterval)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
stopPingInterval() {
|
|
102
|
-
if (this.pingTimer) {
|
|
103
|
-
clearInterval(this.pingTimer)
|
|
104
|
-
this.pingTimer = null
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
stopPolling() {
|
|
109
|
-
if (this.pollingTimer) {
|
|
110
|
-
clearInterval(this.pollingTimer)
|
|
111
|
-
this.pollingTimer = null
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
cleanup() {
|
|
116
|
-
if (this.reconnectTimer) {
|
|
117
|
-
clearTimeout(this.reconnectTimer)
|
|
118
|
-
this.reconnectTimer = null
|
|
119
|
-
}
|
|
120
|
-
this.stopPolling()
|
|
121
|
-
this.stopPingInterval()
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export { CONFIG, ReconnectManager }
|
|
126
|
-
export default ReconnectManager
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
// state-sync channel (server side): real `ws`-backed WebSocketServer with
|
|
2
|
-
// connection-guard IP/rate limiting, part of the state-protocol.js quartet.
|
|
3
|
-
// See the header comment in state-protocol.js for the full picture -- this is
|
|
4
|
-
// a structured, reconnect-aware state-sync protocol, DIFFERENT from and not a
|
|
5
|
-
// replacement for realtime-server.js's simple in-process pub/sub
|
|
6
|
-
// (the "ws-broadcast" channel actually used by the CRUD write path). As of
|
|
7
|
-
// this writing this file has zero real importers repo-wide; nothing
|
|
8
|
-
// constructs a StateTransportServer today.
|
|
9
|
-
import { WebSocketServer } from 'ws'
|
|
10
|
-
import { EventEmitter } from 'events'
|
|
11
|
-
import Protocol from '@/lib/state-protocol.js'
|
|
12
|
-
import ConnectionGuard from '@/lib/connection-guard.js'
|
|
13
|
-
|
|
14
|
-
const CONFIG = {
|
|
15
|
-
pingInterval: 30000,
|
|
16
|
-
connectionTimeout: 60000,
|
|
17
|
-
maxConnectionsPerIP: 10,
|
|
18
|
-
messageRateLimit: 100
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
class StateTransportServer extends EventEmitter {
|
|
22
|
-
constructor(server, config = {}) {
|
|
23
|
-
super()
|
|
24
|
-
this.config = { ...CONFIG, ...config }
|
|
25
|
-
this.wss = null
|
|
26
|
-
this.clients = new Map()
|
|
27
|
-
this.server = server
|
|
28
|
-
this.guard = new ConnectionGuard(this.config)
|
|
29
|
-
this.setupServer()
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
setupServer() {
|
|
33
|
-
try {
|
|
34
|
-
this.wss = new WebSocketServer({ server: this.server, path: '/state-sync' })
|
|
35
|
-
this.wss.on('connection', (ws, req) => this.handleConnection(ws, req))
|
|
36
|
-
this.wss.on('error', (error) => this.handleError(error))
|
|
37
|
-
this.guard.startPingInterval(this.clients, (id) => this.handleClose(id))
|
|
38
|
-
this.emit('ready')
|
|
39
|
-
} catch (error) {
|
|
40
|
-
this.emit('error', error)
|
|
41
|
-
setTimeout(() => this.setupServer(), 5000)
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
handleConnection(ws, req) {
|
|
46
|
-
const clientId = this.guard.generateClientId()
|
|
47
|
-
const ip = req.socket.remoteAddress
|
|
48
|
-
|
|
49
|
-
if (!this.guard.checkConnectionLimit(ip)) {
|
|
50
|
-
ws.close(1008, 'Too many connections from this IP')
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const client = {
|
|
55
|
-
id: clientId,
|
|
56
|
-
ws,
|
|
57
|
-
ip,
|
|
58
|
-
alive: true,
|
|
59
|
-
connectedAt: Date.now(),
|
|
60
|
-
messageCount: 0
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
this.clients.set(clientId, client)
|
|
64
|
-
this.guard.trackIPConnection(ip)
|
|
65
|
-
|
|
66
|
-
ws.on('message', (data) => this.handleMessage(clientId, data))
|
|
67
|
-
ws.on('close', () => this.handleClose(clientId))
|
|
68
|
-
ws.on('error', (error) => this.handleClientError(clientId, error))
|
|
69
|
-
ws.on('pong', () => { client.alive = true })
|
|
70
|
-
|
|
71
|
-
this.emit('client_connected', clientId)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
handleMessage(clientId, data) {
|
|
75
|
-
try {
|
|
76
|
-
const client = this.clients.get(clientId)
|
|
77
|
-
if (!client) return
|
|
78
|
-
|
|
79
|
-
if (!this.guard.checkRateLimit(clientId)) {
|
|
80
|
-
this.sendMessage(clientId, Protocol.createStateNack(
|
|
81
|
-
null,
|
|
82
|
-
'Rate limit exceeded',
|
|
83
|
-
Protocol.ERROR_CODES.RATE_LIMITED
|
|
84
|
-
))
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const message = JSON.parse(data.toString())
|
|
89
|
-
const validation = Protocol.MessageSchema.validate(message)
|
|
90
|
-
|
|
91
|
-
if (!validation.valid) {
|
|
92
|
-
this.sendMessage(clientId, Protocol.createStateNack(
|
|
93
|
-
message.id,
|
|
94
|
-
validation.error,
|
|
95
|
-
Protocol.ERROR_CODES.INVALID_MESSAGE
|
|
96
|
-
))
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
client.messageCount++
|
|
101
|
-
this.emit('message', clientId, message)
|
|
102
|
-
|
|
103
|
-
if (message.type === Protocol.MESSAGE_TYPES.PING) {
|
|
104
|
-
this.sendMessage(clientId, Protocol.createPong(message.timestamp))
|
|
105
|
-
}
|
|
106
|
-
} catch (error) {
|
|
107
|
-
this.emit('error', { clientId, error, phase: 'message_handling' })
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
handleClose(clientId) {
|
|
112
|
-
const client = this.clients.get(clientId)
|
|
113
|
-
if (client) {
|
|
114
|
-
this.guard.untrackIPConnection(client.ip)
|
|
115
|
-
this.clients.delete(clientId)
|
|
116
|
-
this.guard.clearRateLimit(clientId)
|
|
117
|
-
this.emit('client_disconnected', clientId)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
handleClientError(clientId, error) {
|
|
122
|
-
this.emit('error', { clientId, error, phase: 'client_error' })
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
handleError(error) {
|
|
126
|
-
this.emit('error', { error, phase: 'server_error' })
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
sendMessage(clientId, message) {
|
|
130
|
-
try {
|
|
131
|
-
const client = this.clients.get(clientId)
|
|
132
|
-
if (client && client.ws.readyState === 1) {
|
|
133
|
-
client.ws.send(JSON.stringify(message))
|
|
134
|
-
return true
|
|
135
|
-
}
|
|
136
|
-
return false
|
|
137
|
-
} catch (error) {
|
|
138
|
-
this.emit('error', { clientId, error, phase: 'send_message' })
|
|
139
|
-
return false
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
broadcast(message, excludeClientId = null) {
|
|
144
|
-
const results = { sent: 0, failed: 0 }
|
|
145
|
-
for (const [clientId] of this.clients) {
|
|
146
|
-
if (clientId === excludeClientId) continue
|
|
147
|
-
if (this.sendMessage(clientId, message)) {
|
|
148
|
-
results.sent++
|
|
149
|
-
} else {
|
|
150
|
-
results.failed++
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return results
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
getClientCount() {
|
|
157
|
-
return this.clients.size
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
getClient(clientId) {
|
|
161
|
-
return this.clients.get(clientId)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
close() {
|
|
165
|
-
this.guard.destroy()
|
|
166
|
-
for (const client of this.clients.values()) {
|
|
167
|
-
client.ws.close()
|
|
168
|
-
}
|
|
169
|
-
this.clients.clear()
|
|
170
|
-
if (this.wss) {
|
|
171
|
-
this.wss.close()
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (!global.stateTransportServer) {
|
|
177
|
-
global.stateTransportServer = null
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function createStateTransportServer(server, config) {
|
|
181
|
-
if (global.stateTransportServer) {
|
|
182
|
-
global.stateTransportServer.close()
|
|
183
|
-
}
|
|
184
|
-
global.stateTransportServer = new StateTransportServer(server, config)
|
|
185
|
-
return global.stateTransportServer
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export { StateTransportServer }
|
|
189
|
-
export default StateTransportServer
|
package/src/lib/static-server.js
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
|
-
|
|
5
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
const ROOT = path.join(__dirname, '../..');
|
|
7
|
-
|
|
8
|
-
export function serveStatic(pathname, req, res, compress, getCacheHeaders, _loadModule) {
|
|
9
|
-
const acceptEncoding = req.headers['accept-encoding'] || '';
|
|
10
|
-
|
|
11
|
-
if (pathname === '/favicon.ico') {
|
|
12
|
-
res.setHeader('Content-Type', 'image/x-icon');
|
|
13
|
-
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
14
|
-
res.setHeader('Content-Length', '0');
|
|
15
|
-
res.writeHead(200);
|
|
16
|
-
res.end();
|
|
17
|
-
return true;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (pathname === '/manifest.json') {
|
|
21
|
-
const manifest = JSON.stringify({ name: 'Thatcher', short_name: 'Thatcher', start_url: '/', display: 'standalone', background_color: '#f1f5f9', theme_color: '#04141f' });
|
|
22
|
-
res.setHeader('Content-Type', 'application/json');
|
|
23
|
-
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
24
|
-
res.setHeader('Content-Length', Buffer.byteLength(manifest, 'utf-8'));
|
|
25
|
-
res.writeHead(200);
|
|
26
|
-
res.end(manifest);
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (pathname === '/service-worker.js') {
|
|
31
|
-
const swPath = path.join(ROOT, 'src/service-worker.js');
|
|
32
|
-
if (fs.existsSync(swPath)) {
|
|
33
|
-
const content = fs.readFileSync(swPath, 'utf-8');
|
|
34
|
-
const cacheHeaders = getCacheHeaders('dynamic');
|
|
35
|
-
Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
|
|
36
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
37
|
-
res.setHeader('Content-Length', Buffer.byteLength(content, 'utf-8'));
|
|
38
|
-
res.writeHead(200);
|
|
39
|
-
res.end(content);
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (pathname.startsWith('/lib/webjsx/')) {
|
|
45
|
-
const file = pathname.slice(12);
|
|
46
|
-
const filePath = path.join(ROOT, 'node_modules/webjsx/dist', file);
|
|
47
|
-
if (!fs.existsSync(filePath)) return false;
|
|
48
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
49
|
-
const cacheHeaders = getCacheHeaders('static', 31536000);
|
|
50
|
-
Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
|
|
51
|
-
const { content: finalContent, encoding } = compress(content, acceptEncoding);
|
|
52
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
53
|
-
if (encoding) res.setHeader('Content-Encoding', encoding);
|
|
54
|
-
res.setHeader('Content-Length', Buffer.byteLength(finalContent));
|
|
55
|
-
res.writeHead(200);
|
|
56
|
-
res.end(finalContent);
|
|
57
|
-
return true;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (pathname.startsWith('/ui/') && pathname.endsWith('.css')) {
|
|
61
|
-
const cssPath = path.join(ROOT, 'src/ui', path.basename(pathname));
|
|
62
|
-
if (!fs.existsSync(cssPath)) return false;
|
|
63
|
-
let content = fs.readFileSync(cssPath, 'utf-8');
|
|
64
|
-
const etag = `"${content.length}-${fs.statSync(cssPath).mtimeMs.toString(36)}"`;
|
|
65
|
-
if (req.headers['if-none-match'] === etag) { res.writeHead(304); res.end(); return true; }
|
|
66
|
-
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
67
|
-
res.setHeader('ETag', etag);
|
|
68
|
-
const { content: finalContent, encoding } = compress(content, acceptEncoding);
|
|
69
|
-
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
70
|
-
if (encoding) res.setHeader('Content-Encoding', encoding);
|
|
71
|
-
res.setHeader('Content-Length', Buffer.byteLength(finalContent));
|
|
72
|
-
res.writeHead(200);
|
|
73
|
-
res.end(finalContent);
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (pathname === '/ui/client.js' || pathname === '/ui/event-delegation.js' || pathname === '/ui/common-handlers.js') {
|
|
78
|
-
const jsPath = path.join(ROOT, 'src/ui', pathname.split('/').pop());
|
|
79
|
-
if (!fs.existsSync(jsPath)) return false;
|
|
80
|
-
const content = fs.readFileSync(jsPath, 'utf-8');
|
|
81
|
-
const cacheHeaders = getCacheHeaders('static', 86400);
|
|
82
|
-
Object.entries(cacheHeaders).forEach(([k, v]) => res.setHeader(k, v));
|
|
83
|
-
const { content: finalContent, encoding } = compress(content, acceptEncoding);
|
|
84
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
85
|
-
if (encoding) res.setHeader('Content-Encoding', encoding);
|
|
86
|
-
res.setHeader('Content-Length', Buffer.byteLength(finalContent));
|
|
87
|
-
res.writeHead(200);
|
|
88
|
-
res.end(finalContent);
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function html404() {
|
|
96
|
-
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><script>(function(){try{var t=localStorage.getItem('thatcher-theme')||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light');document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script><meta name="viewport" content="width=device-width,initial-scale=1"><title>404 - Page Not Found | Thatcher</title><link href="/ui/rippleui.css" rel="stylesheet"><link href="/ui/styles2.css" rel="stylesheet"><style>body{margin:0;background:var(--color-bg,#f1f5f9);font-family:system-ui,sans-serif}.nav-shell{background:#04141f;padding:0 2rem;height:56px;display:flex;align-items:center}a.logo-link{color:#fff;text-decoration:none;font-weight:700;font-size:1.1rem}.error-shell{min-height:calc(100vh - 56px);display:flex;align-items:center;justify-content:center}.error-card{background:#fff;border-radius:12px;padding:3rem 4rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.1)}.error-code{font-size:4rem;font-weight:900;color:#04141f;line-height:1}.error-msg{font-size:1.2rem;color:#64748b;margin:0.5rem 0 2rem}.home-btn{display:inline-block;padding:0.75rem 2rem;background:#04141f;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.95rem}</style></head><body><nav class="nav-shell"><a href="/" class="logo-link">Thatcher</a></nav><div class="error-shell"><div class="error-card"><div class="error-code">404</div><p class="error-msg">Page not found</p><a href="/" class="home-btn">Go to Dashboard</a></div></div></body></html>`;
|
|
97
|
-
}
|