@tuongaz/seeflow 0.1.107 → 0.1.110
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/web/assets/{architectureDiagram-3BPJPVTR-C4n2LFo2.js → architectureDiagram-3BPJPVTR-CyC7OltI.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-DmYJl-4W.js → blockDiagram-GPEHLZMM-Bzp66cSV.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-BRtrAah9.js → c4Diagram-AAUBKEIU-DG8Iponw.js} +1 -1
- package/dist/web/assets/channel-ByV7mN6x.js +1 -0
- package/dist/web/assets/{chart-BJADfkvF.js → chart-DYrOhreJ.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-Cwse2MLm.js → chunk-2J33WTMH-DAqVVWtt.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-C7yQ1hNl.js → chunk-4BX2VUAB-BLSkydjn.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-C4JH4uPC.js → chunk-55IACEB6-DQPfPbiJ.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-CH7_QMCX.js → chunk-727SXJPM-BTf0cYfp.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-JANvO0oT.js → chunk-AQP2D5EJ-3Vhv9mu3.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-tMpbfhjf.js → chunk-FMBD7UC4-DFLLVi48.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-D9eBypg9.js → chunk-ND2GUHAM-CxNVSIAI.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-geJeqOM8.js → chunk-QZHKN3VN-CgQH2fWj.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-CftRaxtv.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-CftRaxtv.js +1 -0
- package/dist/web/assets/{code-block-DNQhzmPc.js → code-block-DbJK70Ez.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-BddhusFd.js → cose-bilkent-S5V4N54A-Byq3k4cK.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-DgzvId-E.js → dagre-BM42HDAG-BsD1H1iU.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-MDnqZTUO.js → diagram-2AECGRRQ-CtzAGbyS.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-CNp33BbA.js → diagram-5GNKFQAL-D0UfPbiW.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-KiwbE7kH.js → diagram-KO2AKTUF-9h1XRmj3.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-CbVgSz8_.js → diagram-LMA3HP47-BrI6e364.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-CbuPIx97.js → diagram-OG6HWLK6-BFGFDRp3.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-Cb0DMX_h.js → erDiagram-TEJ5UH35-ybC8Itku.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-BXWZO2JG.js → flowDiagram-I6XJVG4X-Dhol-kR8.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-BLNBpY-f.js → ganttDiagram-6RSMTGT7-Y0-j3xBh.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-CezBV45Y.js → gitGraphDiagram-PVQCEYII-CkmdZb3E.js} +1 -1
- package/dist/web/assets/{iconify-Dq4nFnfE.js → iconify-C6Z3PVFi.js} +1 -1
- package/dist/web/assets/index-BpIz1YNe.css +1 -0
- package/dist/web/assets/index-D3ZH7sGq.js +8629 -0
- package/dist/web/assets/{index.es-DcaYIQmY.js → index.es-D2LpuCoX.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-CCWYa3Vp.js → infoDiagram-5YYISTIA-C349fRyb.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CcOMRswO.js → ishikawaDiagram-YF4QCWOH-CdAxgdA0.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-CGh3OzMJ.js → journeyDiagram-JHISSGLW-Gdj93qhh.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-BND60C1X.js → jspdf.es.min-BaNs7eNe.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-wc19qf9j.js → kanban-definition-UN3LZRKU-utWOpj47.js} +1 -1
- package/dist/web/assets/{linear-DI6gQRSO.js → linear-ysH3nWHa.js} +1 -1
- package/dist/web/assets/{markdown-CZ5OQFXP.js → markdown-DKlQMpx8.js} +1 -1
- package/dist/web/assets/{mermaid.core-CAZGzK0l.js → mermaid.core-eyZWidoa.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-DEN6ul-4.js → mindmap-definition-RKZ34NQL-Dhprs5tv.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-BzK5WQ02.js → pieDiagram-4H26LBE5-z_3vPWNO.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-WpYq-pQF.js → quadrantDiagram-W4KKPZXB-BOBDCRWb.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-Ca5ORxLp.js → requirementDiagram-4Y6WPE33-qn0up2ND.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-DBWaB1Is.js → sankeyDiagram-5OEKKPKP-CrcTPUVx.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-BGEYL2Uw.js → sequenceDiagram-3UESZ5HK-r24r7OqC.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-Bt3xrb-F.js → stateDiagram-AJRCARHV-2EJNhDf-.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-YVpQlkEY.js +1 -0
- package/dist/web/assets/{time-DRdnjU-r.js → time-CheTsYvu.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-D3Avg3RH.js → timeline-definition-PNZ67QCA-BNj38YIy.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-DK0Y12Yp.js → vennDiagram-CIIHVFJN-C6ktujmS.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-Th41Nnnt.js → wardley-L42UT6IY-Bl_M5GRw.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-DYq6kX6q.js → wardleyDiagram-YWT4CUSO-B0CVz9vg.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-_nwsSoOi.js → xychartDiagram-2RQKCTM6-ljSwqYiu.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/api.ts +0 -291
- package/src/server.ts +0 -20
- package/dist/web/assets/channel-Cwz40yCU.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-DQJOq1dg.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DQJOq1dg.js +0 -1
- package/dist/web/assets/index-DOA-g04P.js +0 -8644
- package/dist/web/assets/index-TDj3HTQM.css +0 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-DyUVzYNm.js +0 -1
- package/src/share/sse-frame.ts +0 -85
- package/src/share/sse-outbound-queue.ts +0 -173
- package/src/share/sse-rate-limit.ts +0 -205
- package/src/share/sse-tap.ts +0 -183
- package/src/share-audit.ts +0 -267
- package/src/share-envelope.ts +0 -155
- package/src/share-file-request.ts +0 -353
- package/src/share-file-resolver.ts +0 -68
- package/src/share-file-upload.ts +0 -595
- package/src/share-files-manifest.ts +0 -232
- package/src/share-ratelimit.ts +0 -69
- package/src/share-rpc-schema.ts +0 -249
- package/src/share-transport.ts +0 -205
- package/src/share.ts +0 -1663
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host-side files-manifest builder. Walks the currently registered project's
|
|
3
|
-
* per-node folders once on session start and emits one `files-manifest` frame
|
|
4
|
-
* per joining peer so the peer's canvas can prime cache keys and render
|
|
5
|
-
* placeholder sizing BEFORE any `file-request` fires.
|
|
6
|
-
*
|
|
7
|
-
* Manifest is metadata only — no file contents on the wire. Each entry carries
|
|
8
|
-
* the file's size + a sha256 prefix (16 hex chars) so the peer can detect a
|
|
9
|
-
* cache miss without round-tripping the body. The full 64-hex sha is preserved
|
|
10
|
-
* inside file-request / file-redirect payloads (US-060) where integrity matters;
|
|
11
|
-
* here we use a prefix to keep the manifest small.
|
|
12
|
-
*
|
|
13
|
-
* Walk depth is capped at 2 (files directly under `<flowDir>/nodes/<nodeId>/`
|
|
14
|
-
* plus one level of subdirectory) so a peer that drops a folder of nested
|
|
15
|
-
* assets doesn't explode the manifest. Total entries are capped at 5000 —
|
|
16
|
-
* if exceeded the manifest is truncated with a warn; join is never blocked.
|
|
17
|
-
*
|
|
18
|
-
* Incremental updates: after the initial build, `recordFileWritten` / `recordNodeRemoved`
|
|
19
|
-
* keep the in-memory map in sync with `file-upload` node-patched broadcasts +
|
|
20
|
-
* `removeNodeDir` calls, so a peer that joins mid-session sees the same
|
|
21
|
-
* post-upload state every other peer has converged on.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { createHash } from 'node:crypto';
|
|
25
|
-
import type { Dirent } from 'node:fs';
|
|
26
|
-
import * as fsPromises from 'node:fs/promises';
|
|
27
|
-
import { dirname, join, sep as pathSep } from 'node:path';
|
|
28
|
-
import type { Registry } from './registry.ts';
|
|
29
|
-
import type { FilesManifestEntry, FilesManifestPayload } from './share-envelope.ts';
|
|
30
|
-
|
|
31
|
-
export const MAX_MANIFEST_ENTRIES = 5000;
|
|
32
|
-
const ETAG_PREFIX_LEN = 16;
|
|
33
|
-
|
|
34
|
-
interface InternalEntry {
|
|
35
|
-
nodeId: string;
|
|
36
|
-
relPath: string;
|
|
37
|
-
size: number;
|
|
38
|
-
etag: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface FilesManifestDeps {
|
|
42
|
-
registry: Registry;
|
|
43
|
-
// Test seams — defaults to node:fs/promises.
|
|
44
|
-
readdir?: (p: string) => Promise<Dirent[]>;
|
|
45
|
-
readFile?: (p: string) => Promise<Buffer>;
|
|
46
|
-
stat?: (p: string) => Promise<{ size: number }>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface FilesManifestBuilder {
|
|
50
|
-
/** Walk every registered flow's nodes/ subtree and populate the in-memory map.
|
|
51
|
-
* Safe to call repeatedly — replaces the existing state. */
|
|
52
|
-
init(): Promise<void>;
|
|
53
|
-
/** Snapshot of the current manifest, capped at MAX_MANIFEST_ENTRIES. */
|
|
54
|
-
build(): FilesManifestPayload;
|
|
55
|
-
/** Record (or replace) a single file entry. Called by share.ts when a
|
|
56
|
-
* `file-upload` `node-patched` broadcast fires. */
|
|
57
|
-
recordFileWritten(input: {
|
|
58
|
-
absPath: string;
|
|
59
|
-
nodeId: string;
|
|
60
|
-
relPath: string;
|
|
61
|
-
size: number;
|
|
62
|
-
etag: string;
|
|
63
|
-
}): void;
|
|
64
|
-
/** Compute size + etag from raw bytes then `recordFileWritten`. Convenience
|
|
65
|
-
* for callers that already hold the post-write buffer. */
|
|
66
|
-
recordFileWrittenFromBytes(input: {
|
|
67
|
-
absPath: string;
|
|
68
|
-
nodeId: string;
|
|
69
|
-
relPath: string;
|
|
70
|
-
bytes: Uint8Array;
|
|
71
|
-
}): void;
|
|
72
|
-
/** Drop every entry whose `absPath` lives under the given node directory. */
|
|
73
|
-
recordNodeRemoved(nodeDirAbsPath: string): void;
|
|
74
|
-
/** Test/debug visibility into the underlying map. */
|
|
75
|
-
size(): number;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const NODE_ID_RE = /^node-[A-Za-z0-9]{10}$/;
|
|
79
|
-
|
|
80
|
-
const defaultReaddir = (p: string): Promise<Dirent[]> =>
|
|
81
|
-
fsPromises.readdir(p, { withFileTypes: true });
|
|
82
|
-
const defaultReadFile = (p: string): Promise<Buffer> => fsPromises.readFile(p) as Promise<Buffer>;
|
|
83
|
-
const defaultStat = async (p: string): Promise<{ size: number }> => {
|
|
84
|
-
const s = await fsPromises.stat(p);
|
|
85
|
-
return { size: s.size };
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const etagFromBytes = (bytes: Uint8Array): string =>
|
|
89
|
-
createHash('sha256').update(bytes).digest('hex').slice(0, ETAG_PREFIX_LEN);
|
|
90
|
-
|
|
91
|
-
const flowDirOf = (flowPath: string): string => {
|
|
92
|
-
const d = dirname(flowPath);
|
|
93
|
-
return d === '.' ? '' : d;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const flowBaseAbs = (repoPath: string, flowPath: string): string => {
|
|
97
|
-
const fd = flowDirOf(flowPath);
|
|
98
|
-
return fd === '' ? repoPath : join(repoPath, fd);
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
export function createFilesManifestBuilder(deps: FilesManifestDeps): FilesManifestBuilder {
|
|
102
|
-
const readdir = deps.readdir ?? defaultReaddir;
|
|
103
|
-
const readFile = deps.readFile ?? defaultReadFile;
|
|
104
|
-
const stat = deps.stat ?? defaultStat;
|
|
105
|
-
|
|
106
|
-
// absPath -> entry. absPath chosen as the key because (a) it's the only
|
|
107
|
-
// identifier that uniquely names a file across flows and (b) it lets
|
|
108
|
-
// `recordNodeRemoved(nodeDirAbsPath)` drop everything under a single prefix
|
|
109
|
-
// without a secondary index.
|
|
110
|
-
const entries = new Map<string, InternalEntry>();
|
|
111
|
-
|
|
112
|
-
const walkNodeDir = async (flowBase: string, nodeId: string): Promise<void> => {
|
|
113
|
-
const nodeDir = join(flowBase, 'nodes', nodeId);
|
|
114
|
-
let level1: Dirent[];
|
|
115
|
-
try {
|
|
116
|
-
level1 = await readdir(nodeDir);
|
|
117
|
-
} catch {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
for (const d of level1) {
|
|
121
|
-
if (d.isSymbolicLink()) continue;
|
|
122
|
-
if (d.isFile()) {
|
|
123
|
-
await ingestFile(nodeDir, nodeId, d.name, [d.name]);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
if (!d.isDirectory()) continue;
|
|
127
|
-
// Depth 2: one level of subdirectory. Files inside are included; nested
|
|
128
|
-
// directories below this are NOT descended into.
|
|
129
|
-
const subDir = join(nodeDir, d.name);
|
|
130
|
-
let level2: Dirent[];
|
|
131
|
-
try {
|
|
132
|
-
level2 = await readdir(subDir);
|
|
133
|
-
} catch {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
for (const inner of level2) {
|
|
137
|
-
if (inner.isSymbolicLink()) continue;
|
|
138
|
-
if (!inner.isFile()) continue;
|
|
139
|
-
await ingestFile(subDir, nodeId, inner.name, [d.name, inner.name]);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const ingestFile = async (
|
|
145
|
-
fileDir: string,
|
|
146
|
-
nodeId: string,
|
|
147
|
-
fileName: string,
|
|
148
|
-
relSegments: string[],
|
|
149
|
-
): Promise<void> => {
|
|
150
|
-
const absPath = join(fileDir, fileName);
|
|
151
|
-
// Per-file relPath is relative to the flow folder. Always forward-slash
|
|
152
|
-
// separated so the wire payload is platform-neutral.
|
|
153
|
-
const relPath = `nodes/${nodeId}/${relSegments.join('/')}`;
|
|
154
|
-
let size = 0;
|
|
155
|
-
let etag = '';
|
|
156
|
-
try {
|
|
157
|
-
const st = await stat(absPath);
|
|
158
|
-
size = st.size;
|
|
159
|
-
} catch {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
try {
|
|
163
|
-
const bytes = await readFile(absPath);
|
|
164
|
-
etag = etagFromBytes(bytes);
|
|
165
|
-
} catch {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
entries.set(absPath, { nodeId, relPath, size, etag });
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
async init() {
|
|
173
|
-
entries.clear();
|
|
174
|
-
for (const entry of deps.registry.list()) {
|
|
175
|
-
const flowBase = flowBaseAbs(entry.repoPath, entry.flowPath);
|
|
176
|
-
const nodesRoot = join(flowBase, 'nodes');
|
|
177
|
-
let nodeDirs: Dirent[];
|
|
178
|
-
try {
|
|
179
|
-
nodeDirs = await readdir(nodesRoot);
|
|
180
|
-
} catch {
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
for (const d of nodeDirs) {
|
|
184
|
-
if (d.isSymbolicLink()) continue;
|
|
185
|
-
if (!d.isDirectory()) continue;
|
|
186
|
-
if (!NODE_ID_RE.test(d.name)) continue;
|
|
187
|
-
await walkNodeDir(flowBase, d.name);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
build() {
|
|
192
|
-
const all = [...entries.values()].map(
|
|
193
|
-
({ nodeId, relPath, size, etag }): FilesManifestEntry => ({
|
|
194
|
-
nodeId,
|
|
195
|
-
relPath,
|
|
196
|
-
size,
|
|
197
|
-
etag,
|
|
198
|
-
}),
|
|
199
|
-
);
|
|
200
|
-
if (all.length > MAX_MANIFEST_ENTRIES) {
|
|
201
|
-
console.warn(
|
|
202
|
-
`[share] files-manifest truncated: ${all.length} entries exceeds cap ${MAX_MANIFEST_ENTRIES}`,
|
|
203
|
-
);
|
|
204
|
-
return { entries: all.slice(0, MAX_MANIFEST_ENTRIES) };
|
|
205
|
-
}
|
|
206
|
-
return { entries: all };
|
|
207
|
-
},
|
|
208
|
-
recordFileWritten({ absPath, nodeId, relPath, size, etag }) {
|
|
209
|
-
entries.set(absPath, { nodeId, relPath, size, etag });
|
|
210
|
-
},
|
|
211
|
-
recordFileWrittenFromBytes({ absPath, nodeId, relPath, bytes }) {
|
|
212
|
-
entries.set(absPath, {
|
|
213
|
-
nodeId,
|
|
214
|
-
relPath,
|
|
215
|
-
size: bytes.byteLength,
|
|
216
|
-
etag: etagFromBytes(bytes),
|
|
217
|
-
});
|
|
218
|
-
},
|
|
219
|
-
recordNodeRemoved(nodeDirAbsPath) {
|
|
220
|
-
// Match either the directory itself or anything strictly under it. A
|
|
221
|
-
// bare prefix check would falsely match a sibling directory whose name
|
|
222
|
-
// starts with `nodeDirAbsPath` (e.g. `/a/node-x` vs `/a/node-xx-data`).
|
|
223
|
-
const prefix = nodeDirAbsPath.endsWith(pathSep) ? nodeDirAbsPath : nodeDirAbsPath + pathSep;
|
|
224
|
-
for (const key of entries.keys()) {
|
|
225
|
-
if (key === nodeDirAbsPath || key.startsWith(prefix)) entries.delete(key);
|
|
226
|
-
}
|
|
227
|
-
},
|
|
228
|
-
size() {
|
|
229
|
-
return entries.size;
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
}
|
package/src/share-ratelimit.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-peer token-bucket rate limiter for inbound share frames.
|
|
3
|
-
*
|
|
4
|
-
* Each peer holds a bucket of `burst` tokens, refilling at `ratePerSec`
|
|
5
|
-
* tokens/second. check() deducts a token if available and returns ok; if
|
|
6
|
-
* empty, it returns the milliseconds the caller should wait before retrying.
|
|
7
|
-
* Time is injected via `now()` so tests can drive the bucket synchronously.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export interface RateLimitOk {
|
|
11
|
-
ok: true;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface RateLimitDenied {
|
|
15
|
-
ok: false;
|
|
16
|
-
retryAfterMs: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type RateLimitResult = RateLimitOk | RateLimitDenied;
|
|
20
|
-
|
|
21
|
-
export interface RateLimiter {
|
|
22
|
-
check(peerId: string): RateLimitResult;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface RateLimiterOpts {
|
|
26
|
-
ratePerSec: number;
|
|
27
|
-
burst?: number;
|
|
28
|
-
now?: () => number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface Bucket {
|
|
32
|
-
tokens: number;
|
|
33
|
-
lastRefillMs: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function createRateLimiter(opts: RateLimiterOpts): RateLimiter {
|
|
37
|
-
const ratePerSec = opts.ratePerSec;
|
|
38
|
-
const capacity = opts.burst ?? opts.ratePerSec;
|
|
39
|
-
const nowFn = opts.now ?? Date.now;
|
|
40
|
-
const buckets = new Map<string, Bucket>();
|
|
41
|
-
|
|
42
|
-
const refill = (bucket: Bucket, now: number) => {
|
|
43
|
-
const elapsedSec = (now - bucket.lastRefillMs) / 1000;
|
|
44
|
-
if (elapsedSec <= 0) return;
|
|
45
|
-
const refilled = bucket.tokens + elapsedSec * ratePerSec;
|
|
46
|
-
bucket.tokens = refilled > capacity ? capacity : refilled;
|
|
47
|
-
bucket.lastRefillMs = now;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
check(peerId) {
|
|
52
|
-
const now = nowFn();
|
|
53
|
-
let bucket = buckets.get(peerId);
|
|
54
|
-
if (!bucket) {
|
|
55
|
-
bucket = { tokens: capacity, lastRefillMs: now };
|
|
56
|
-
buckets.set(peerId, bucket);
|
|
57
|
-
} else {
|
|
58
|
-
refill(bucket, now);
|
|
59
|
-
}
|
|
60
|
-
if (bucket.tokens >= 1) {
|
|
61
|
-
bucket.tokens -= 1;
|
|
62
|
-
return { ok: true };
|
|
63
|
-
}
|
|
64
|
-
const deficit = 1 - bucket.tokens;
|
|
65
|
-
const retryAfterMs = Math.ceil((deficit / ratePerSec) * 1000);
|
|
66
|
-
return { ok: false, retryAfterMs };
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
}
|
package/src/share-rpc-schema.ts
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared RPC envelope schema for the live-share WebSocket protocol (phase 4).
|
|
3
|
-
*
|
|
4
|
-
* ⚠ BYTE-FOR-BYTE MIRROR: this file MUST stay identical to
|
|
5
|
-
* /Users/tuongaz/dev/seeflow-viewer/src/lib/share-rpc-schema.ts.
|
|
6
|
-
* The integration check at apps/studio/integration/share-rpc-schema-sync.it.ts
|
|
7
|
-
* reads both files and asserts strict bytewise equality. Any edit here MUST
|
|
8
|
-
* be mirrored to the viewer copy in the same commit, or CI fails.
|
|
9
|
-
*
|
|
10
|
-
* Self-contained on purpose: NO imports from './operations.ts' or './schema.ts'.
|
|
11
|
-
* Those paths don't resolve in the viewer repo, and byte-for-byte mirroring
|
|
12
|
-
* requires identical imports across both files.
|
|
13
|
-
*
|
|
14
|
-
* Body-shape strictness:
|
|
15
|
-
* - PositionBody + ReorderBody are tiny self-contained Zod shapes — inlined
|
|
16
|
-
* here so obviously-malformed payloads (NaN x, unknown reorder op) reject
|
|
17
|
-
* at the wire layer.
|
|
18
|
-
* - Node/connector add + patch bodies are loose `z.record(z.unknown())`. The
|
|
19
|
-
* strict re-validation runs at impl-dispatch time in operations.ts via
|
|
20
|
-
* `NodePatchBodySchema` / `ConnectorPatchBodySchema` / the post-merge
|
|
21
|
-
* `ResolvedFlowSchema` reparse (US-038's `handleRpcFrame`). Treat this file
|
|
22
|
-
* as an envelope schema, not a substitute for the impl-layer guards.
|
|
23
|
-
*
|
|
24
|
-
* Op allowlist (9 ops): addNode, patchNode, moveNode, reorderNode, deleteNode,
|
|
25
|
-
* addConnector, patchConnector, deleteConnector, addBulk. Unknown ops are
|
|
26
|
-
* rejected by the discriminated union; the per-op `.strict()` rejects unknown
|
|
27
|
-
* top-level keys so a `{ op: 'addNode', nodeId, position }` cross-op shape
|
|
28
|
-
* fails fast instead of silently accepting the wrong wrapper.
|
|
29
|
-
*
|
|
30
|
-
* `addBulk` is the only op without `.strict()` because its body carries the
|
|
31
|
-
* optional `nodes` + `connectors` arrays alongside `op` + `flowId`; the
|
|
32
|
-
* 100-item-per-array cap mirrors operations.ts's `FlowBulkBodyShape`. The
|
|
33
|
-
* "at least one non-empty" refine from `FlowBulkBodySchema` is NOT applied
|
|
34
|
-
* here (discriminatedUnion requires bare ZodObjects, not ZodEffects) — the
|
|
35
|
-
* non-empty check runs in US-038's handler before dispatching to
|
|
36
|
-
* `addFlowBulkImpl`.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
import { z } from 'zod';
|
|
40
|
-
|
|
41
|
-
const FlowIdSchema = z.string().min(1);
|
|
42
|
-
const NodeIdSchema = z.string().min(1);
|
|
43
|
-
const ConnectorIdSchema = z.string().min(1);
|
|
44
|
-
const FrameIdSchema = z.string().min(1);
|
|
45
|
-
|
|
46
|
-
const PositionSchema = z.object({
|
|
47
|
-
x: z.number().finite(),
|
|
48
|
-
y: z.number().finite(),
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const ReorderSchema = z.discriminatedUnion('op', [
|
|
52
|
-
z.object({ op: z.literal('forward') }),
|
|
53
|
-
z.object({ op: z.literal('backward') }),
|
|
54
|
-
z.object({ op: z.literal('toFront') }),
|
|
55
|
-
z.object({ op: z.literal('toBack') }),
|
|
56
|
-
z.object({ op: z.literal('toIndex'), index: z.number().int().nonnegative() }),
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
const NodeBodySchema = z.record(z.unknown());
|
|
60
|
-
const ConnectorBodySchema = z.record(z.unknown());
|
|
61
|
-
const NodePatchBodyShape = z.record(z.unknown());
|
|
62
|
-
const ConnectorPatchBodyShape = z.record(z.unknown());
|
|
63
|
-
|
|
64
|
-
const BULK_MAX_ITEMS = 100;
|
|
65
|
-
const BulkArraySchema = z.array(z.record(z.unknown())).max(BULK_MAX_ITEMS);
|
|
66
|
-
|
|
67
|
-
export const AddNodeOpSchema = z
|
|
68
|
-
.object({
|
|
69
|
-
op: z.literal('addNode'),
|
|
70
|
-
flowId: FlowIdSchema,
|
|
71
|
-
node: NodeBodySchema,
|
|
72
|
-
})
|
|
73
|
-
.strict();
|
|
74
|
-
|
|
75
|
-
export const PatchNodeOpSchema = z
|
|
76
|
-
.object({
|
|
77
|
-
op: z.literal('patchNode'),
|
|
78
|
-
flowId: FlowIdSchema,
|
|
79
|
-
nodeId: NodeIdSchema,
|
|
80
|
-
patch: NodePatchBodyShape,
|
|
81
|
-
})
|
|
82
|
-
.strict();
|
|
83
|
-
|
|
84
|
-
export const MoveNodeOpSchema = z
|
|
85
|
-
.object({
|
|
86
|
-
op: z.literal('moveNode'),
|
|
87
|
-
flowId: FlowIdSchema,
|
|
88
|
-
nodeId: NodeIdSchema,
|
|
89
|
-
position: PositionSchema,
|
|
90
|
-
})
|
|
91
|
-
.strict();
|
|
92
|
-
|
|
93
|
-
export const ReorderNodeOpSchema = z
|
|
94
|
-
.object({
|
|
95
|
-
op: z.literal('reorderNode'),
|
|
96
|
-
flowId: FlowIdSchema,
|
|
97
|
-
nodeId: NodeIdSchema,
|
|
98
|
-
reorder: ReorderSchema,
|
|
99
|
-
})
|
|
100
|
-
.strict();
|
|
101
|
-
|
|
102
|
-
export const DeleteNodeOpSchema = z
|
|
103
|
-
.object({
|
|
104
|
-
op: z.literal('deleteNode'),
|
|
105
|
-
flowId: FlowIdSchema,
|
|
106
|
-
nodeId: NodeIdSchema,
|
|
107
|
-
})
|
|
108
|
-
.strict();
|
|
109
|
-
|
|
110
|
-
export const AddConnectorOpSchema = z
|
|
111
|
-
.object({
|
|
112
|
-
op: z.literal('addConnector'),
|
|
113
|
-
flowId: FlowIdSchema,
|
|
114
|
-
connector: ConnectorBodySchema,
|
|
115
|
-
})
|
|
116
|
-
.strict();
|
|
117
|
-
|
|
118
|
-
export const PatchConnectorOpSchema = z
|
|
119
|
-
.object({
|
|
120
|
-
op: z.literal('patchConnector'),
|
|
121
|
-
flowId: FlowIdSchema,
|
|
122
|
-
connectorId: ConnectorIdSchema,
|
|
123
|
-
patch: ConnectorPatchBodyShape,
|
|
124
|
-
})
|
|
125
|
-
.strict();
|
|
126
|
-
|
|
127
|
-
export const DeleteConnectorOpSchema = z
|
|
128
|
-
.object({
|
|
129
|
-
op: z.literal('deleteConnector'),
|
|
130
|
-
flowId: FlowIdSchema,
|
|
131
|
-
connectorId: ConnectorIdSchema,
|
|
132
|
-
})
|
|
133
|
-
.strict();
|
|
134
|
-
|
|
135
|
-
export const AddBulkOpSchema = z
|
|
136
|
-
.object({
|
|
137
|
-
op: z.literal('addBulk'),
|
|
138
|
-
flowId: FlowIdSchema,
|
|
139
|
-
nodes: BulkArraySchema.optional(),
|
|
140
|
-
connectors: BulkArraySchema.optional(),
|
|
141
|
-
})
|
|
142
|
-
.strict();
|
|
143
|
-
|
|
144
|
-
export const RpcOpSchema = z.discriminatedUnion('op', [
|
|
145
|
-
AddNodeOpSchema,
|
|
146
|
-
PatchNodeOpSchema,
|
|
147
|
-
MoveNodeOpSchema,
|
|
148
|
-
ReorderNodeOpSchema,
|
|
149
|
-
DeleteNodeOpSchema,
|
|
150
|
-
AddConnectorOpSchema,
|
|
151
|
-
PatchConnectorOpSchema,
|
|
152
|
-
DeleteConnectorOpSchema,
|
|
153
|
-
AddBulkOpSchema,
|
|
154
|
-
]);
|
|
155
|
-
|
|
156
|
-
export const RpcFrameSchema = z
|
|
157
|
-
.object({
|
|
158
|
-
v: z.literal(1),
|
|
159
|
-
type: z.literal('rpc'),
|
|
160
|
-
id: FrameIdSchema,
|
|
161
|
-
payload: RpcOpSchema,
|
|
162
|
-
})
|
|
163
|
-
.strict();
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Attribution metadata attached to every accepted edit. `peerId` is either the
|
|
167
|
-
* literal `'host'` (when the studio owner originates the edit) or matches a
|
|
168
|
-
* known remote peer's id. `displayName` is the human label rendered by the
|
|
169
|
-
* peer SPA's toast / cursor label.
|
|
170
|
-
*
|
|
171
|
-
* Cross-validation (peerId === 'host' OR peerId ∈ knownPeers) is enforced at
|
|
172
|
-
* runtime by the share controller — the wire schema can't reach the peer set,
|
|
173
|
-
* so it accepts any non-empty string and the controller drops broadcasts with
|
|
174
|
-
* peerIds that don't match.
|
|
175
|
-
*/
|
|
176
|
-
export const AttributionSchema = z
|
|
177
|
-
.object({
|
|
178
|
-
peerId: z.string().min(1),
|
|
179
|
-
displayName: z.string().min(1),
|
|
180
|
-
})
|
|
181
|
-
.strict();
|
|
182
|
-
|
|
183
|
-
export type Attribution = z.infer<typeof AttributionSchema>;
|
|
184
|
-
|
|
185
|
-
export const RpcResultPayloadSchema = z.discriminatedUnion('ok', [
|
|
186
|
-
z
|
|
187
|
-
.object({
|
|
188
|
-
ok: z.literal(true),
|
|
189
|
-
result: z.unknown().optional(),
|
|
190
|
-
attributedTo: AttributionSchema.optional(),
|
|
191
|
-
})
|
|
192
|
-
.strict(),
|
|
193
|
-
z.object({ ok: z.literal(false), reason: z.string() }).strict(),
|
|
194
|
-
]);
|
|
195
|
-
|
|
196
|
-
export const RpcResultFrameSchema = z
|
|
197
|
-
.object({
|
|
198
|
-
v: z.literal(1),
|
|
199
|
-
type: z.literal('rpc-result'),
|
|
200
|
-
id: FrameIdSchema,
|
|
201
|
-
payload: RpcResultPayloadSchema,
|
|
202
|
-
})
|
|
203
|
-
.strict();
|
|
204
|
-
|
|
205
|
-
export type RpcOp = z.infer<typeof RpcOpSchema>;
|
|
206
|
-
export type RpcFrame = z.infer<typeof RpcFrameSchema>;
|
|
207
|
-
export type RpcResultFrame = z.infer<typeof RpcResultFrameSchema>;
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* `node-patched` broadcast frame (US-039 host → US-042 peer apply).
|
|
211
|
-
*
|
|
212
|
-
* Host emits this after every accepted `rpc` op so all peers (including the
|
|
213
|
-
* originator) converge on the canonical post-op snapshot. Payload shape mirrors
|
|
214
|
-
* `computeNodePatchedDiff` in `apps/studio/src/share.ts`:
|
|
215
|
-
*
|
|
216
|
-
* { flowId, op, version, diff: { kind, ...fields } }
|
|
217
|
-
*
|
|
218
|
-
* where `op` is one of the 9 RpcOp strings and `diff.kind` is one of
|
|
219
|
-
* 'move' | 'patch' | 'add' | 'delete' | 'reorder' | 'bulk'
|
|
220
|
-
* (the latter only for `addBulk`). The diff body is intentionally loose at
|
|
221
|
-
* the wire layer — peers switch on `op` + `diff.kind` to drive the apply.
|
|
222
|
-
* Strict diff shapes live host-side in `computeNodePatchedDiff`.
|
|
223
|
-
*
|
|
224
|
-
* `version` is a per-flow monotonic counter. Peers track
|
|
225
|
-
* `lastAppliedVersion[flowId]` and drop frames where `version <=` the last
|
|
226
|
-
* applied — guards against out-of-order delivery on reconnect.
|
|
227
|
-
*/
|
|
228
|
-
const NodePatchedDiffSchema = z.record(z.unknown());
|
|
229
|
-
|
|
230
|
-
export const NodePatchedFramePayloadSchema = z
|
|
231
|
-
.object({
|
|
232
|
-
flowId: FlowIdSchema,
|
|
233
|
-
op: z.string().min(1),
|
|
234
|
-
version: z.number().int().nonnegative(),
|
|
235
|
-
diff: NodePatchedDiffSchema,
|
|
236
|
-
attributedTo: AttributionSchema,
|
|
237
|
-
})
|
|
238
|
-
.passthrough();
|
|
239
|
-
|
|
240
|
-
export const NodePatchedFrameSchema = z
|
|
241
|
-
.object({
|
|
242
|
-
v: z.literal(1),
|
|
243
|
-
type: z.literal('node-patched'),
|
|
244
|
-
payload: NodePatchedFramePayloadSchema,
|
|
245
|
-
})
|
|
246
|
-
.passthrough();
|
|
247
|
-
|
|
248
|
-
export type NodePatchedFramePayload = z.infer<typeof NodePatchedFramePayloadSchema>;
|
|
249
|
-
export type NodePatchedFrame = z.infer<typeof NodePatchedFrameSchema>;
|