@tuongaz/seeflow 0.1.102 → 0.1.104
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-Cy5G99GE.js → architectureDiagram-3BPJPVTR-S06hgR1x.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-B1gN1ugF.js → blockDiagram-GPEHLZMM-ZdAkZGrj.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-CYuuPtx8.js → c4Diagram-AAUBKEIU-C5MfRz02.js} +1 -1
- package/dist/web/assets/channel-D-VTcFiH.js +1 -0
- package/dist/web/assets/{chart-YfoAGRGq.js → chart-BxTNHoKY.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-B4sFED7K.js → chunk-2J33WTMH-Bn46MReV.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-D2Pr1i6l.js → chunk-4BX2VUAB-CXHVHaLs.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-B0QJVegf.js → chunk-55IACEB6-CRgBKrhO.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-Dc2HoDqb.js → chunk-727SXJPM-BH62wjtB.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-D30cXxxk.js → chunk-AQP2D5EJ-RxGAqK2A.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-DsgHMGgF.js → chunk-FMBD7UC4-Cb-wgtwe.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-n6Sr5izJ.js → chunk-ND2GUHAM-CJlFQBdh.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-BA1s2pgD.js → chunk-QZHKN3VN-DjoTSKYv.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-DoPcjHFc.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DoPcjHFc.js +1 -0
- package/dist/web/assets/{code-block-CeM4DS5T.js → code-block-DwqzBBWk.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-gJFCrqp6.js → cose-bilkent-S5V4N54A-DpGzn2ro.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-DJgtzuMT.js → dagre-BM42HDAG-CUapj-lK.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-DfqMYNjs.js → diagram-2AECGRRQ-7ISjO-i-.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-Bqivakd0.js → diagram-5GNKFQAL-DCPWI1Ok.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-Cm0eKgu4.js → diagram-KO2AKTUF-zSsOIiq1.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-DCzPDQwE.js → diagram-LMA3HP47-BcqSq_nr.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-L5YX65FQ.js → diagram-OG6HWLK6-Dw6nG7MD.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-Ds6LiAGH.js → erDiagram-TEJ5UH35-B3QPOZAY.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-D50Ubn70.js → flowDiagram-I6XJVG4X-Ceu3sQuX.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-Dkaswtst.js → ganttDiagram-6RSMTGT7-B26peYjY.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-OaI-_pfD.js → gitGraphDiagram-PVQCEYII-CX8DOFLm.js} +1 -1
- package/dist/web/assets/{iconify-MlxYu4AY.js → iconify-DAxofKes.js} +1 -1
- package/dist/web/assets/{index-B6U4bGj0.js → index-B7r8KKap.js} +1758 -1743
- package/dist/web/assets/{index.es-BNr31JLP.js → index.es-wzlW-y-I.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-dIvLrSFw.js → infoDiagram-5YYISTIA-DDVBGLt8.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-LveMmZUw.js → ishikawaDiagram-YF4QCWOH-C6qCp-Zn.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-B0NT14UG.js → journeyDiagram-JHISSGLW-DX8AxLaA.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-DIDY-jNM.js → jspdf.es.min-ykdbTFYz.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-6Ze2WLaL.js → kanban-definition-UN3LZRKU-B8-itdXZ.js} +1 -1
- package/dist/web/assets/{linear-lr2_gH5v.js → linear-Dqxq5R6C.js} +1 -1
- package/dist/web/assets/{markdown-71DgXg9K.js → markdown-D9XDrg2s.js} +1 -1
- package/dist/web/assets/{mermaid.core-CSskwtFd.js → mermaid.core-DujK2Rzn.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL-55BhA-Da.js → mindmap-definition-RKZ34NQL-CXa-5n8a.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-CMysRCVC.js → pieDiagram-4H26LBE5-CGlnQV5E.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BDfPYcaj.js → quadrantDiagram-W4KKPZXB-L_Ap8kcw.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNfdldEP.js → requirementDiagram-4Y6WPE33-B2Io1TQi.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-C_wMGAZ1.js → sankeyDiagram-5OEKKPKP-DeARBi7s.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-CbGM_Yv-.js → sequenceDiagram-3UESZ5HK-BR0jm0Tk.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-BCSHXjtA.js → stateDiagram-AJRCARHV-Ck_33KQ3.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-D122sbeB.js +1 -0
- package/dist/web/assets/{time-Dm45mmVJ.js → time-DY4vPLRk.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-Cv6Hh2hS.js → timeline-definition-PNZ67QCA-DMm8bfvY.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-CNaR42oY.js → vennDiagram-CIIHVFJN-BEfqdSFT.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-BDpknWMg.js → wardley-L42UT6IY-Bmxjf0CJ.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BxIDc-nV.js → wardleyDiagram-YWT4CUSO-D4ialTz3.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-8Ir0d3kF.js → xychartDiagram-2RQKCTM6-nm_TLxlx.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/api.ts +241 -0
- package/src/atomic-write.ts +1 -1
- package/src/server.ts +19 -0
- package/src/share/sse-frame.ts +85 -0
- package/src/share/sse-outbound-queue.ts +173 -0
- package/src/share/sse-rate-limit.ts +205 -0
- package/src/share/sse-tap.ts +183 -0
- package/src/share-audit.ts +267 -0
- package/src/share-envelope.ts +152 -0
- package/src/share-file-request.ts +353 -0
- package/src/share-file-resolver.ts +68 -0
- package/src/share-file-upload.ts +595 -0
- package/src/share-files-manifest.ts +232 -0
- package/src/share-ratelimit.ts +69 -0
- package/src/share-rpc-schema.ts +249 -0
- package/src/share-transport.ts +205 -0
- package/src/share.ts +1561 -0
- package/dist/web/assets/channel-VQJmKMDU.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-Biiw_jnD.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Biiw_jnD.js +0 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Bbabof8W.js +0 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Share WebSocket envelope schema and helpers.
|
|
3
|
+
*
|
|
4
|
+
* Every frame on the relay wire — host->relay and relay->peer — is shaped
|
|
5
|
+
* exactly like the design doc's envelope (v=1, typed `type`, optional `id`
|
|
6
|
+
* for rpc correlation, `from` connId, optional `to`, opaque `payload`).
|
|
7
|
+
* The relay is a dumb router and never inspects `payload`; validation
|
|
8
|
+
* happens at the host (this module) and the peer canvas.
|
|
9
|
+
*
|
|
10
|
+
* `parseEnvelope` returns a discriminated `{ ok }` result so the transport
|
|
11
|
+
* can drop invalid frames without throwing inside the WS read loop.
|
|
12
|
+
* `makeEnvelope` constructs outbound frames with `from='host'` by default
|
|
13
|
+
* so callers don't have to repeat it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
export const ENVELOPE_TYPES = [
|
|
19
|
+
'auth-host',
|
|
20
|
+
'auth-peer',
|
|
21
|
+
'rpc',
|
|
22
|
+
'rpc-result',
|
|
23
|
+
'sse',
|
|
24
|
+
'sse-snapshot',
|
|
25
|
+
'presence',
|
|
26
|
+
'file-request',
|
|
27
|
+
'file-bytes',
|
|
28
|
+
'file-redirect',
|
|
29
|
+
'file-upload-intent',
|
|
30
|
+
'file-upload-done',
|
|
31
|
+
'node-patched',
|
|
32
|
+
'files-manifest',
|
|
33
|
+
'kick',
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export const EnvelopeTypeSchema = z.enum(ENVELOPE_TYPES);
|
|
37
|
+
|
|
38
|
+
export const EnvelopeSchema = z.object({
|
|
39
|
+
v: z.literal(1),
|
|
40
|
+
type: EnvelopeTypeSchema,
|
|
41
|
+
id: z.string().optional(),
|
|
42
|
+
from: z.string(),
|
|
43
|
+
to: z.string().optional(),
|
|
44
|
+
payload: z.unknown(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type EnvelopeType = z.infer<typeof EnvelopeTypeSchema>;
|
|
48
|
+
export type Envelope = z.infer<typeof EnvelopeSchema>;
|
|
49
|
+
|
|
50
|
+
// File frame payload schemas. Vendored copy of the cloud relay's per-type
|
|
51
|
+
// schemas (`cloud/lambda/share/shared/envelope.ts`) so the host can validate
|
|
52
|
+
// inbound file-request frames and shape outbound file-bytes / file-redirect /
|
|
53
|
+
// file-upload-intent / file-upload-done / files-manifest replies with the same
|
|
54
|
+
// invariants the relay enforces. Keep these aligned with the cloud copy.
|
|
55
|
+
const NodeIdSchema = z.string().regex(/^node-[A-Za-z0-9]{10}$/);
|
|
56
|
+
const Sha256HexSchema = z.string().length(64);
|
|
57
|
+
|
|
58
|
+
export const FileRequestPayloadSchema = z.object({
|
|
59
|
+
reqId: z.string().min(1),
|
|
60
|
+
nodeId: z.string(),
|
|
61
|
+
relPath: z.string(),
|
|
62
|
+
});
|
|
63
|
+
export type FileRequestPayload = z.infer<typeof FileRequestPayloadSchema>;
|
|
64
|
+
|
|
65
|
+
export const FileBytesPayloadSchema = z.object({
|
|
66
|
+
reqId: z.string(),
|
|
67
|
+
seq: z.number().int().nonnegative(),
|
|
68
|
+
total: z.number().int().nonnegative(),
|
|
69
|
+
base64: z.string(),
|
|
70
|
+
contentType: z.string().optional(),
|
|
71
|
+
sha256: z.string(),
|
|
72
|
+
eof: z.boolean(),
|
|
73
|
+
});
|
|
74
|
+
export type FileBytesPayload = z.infer<typeof FileBytesPayloadSchema>;
|
|
75
|
+
|
|
76
|
+
export const FileRedirectPayloadSchema = z.object({
|
|
77
|
+
reqId: z.string(),
|
|
78
|
+
getUrl: z.string().url(),
|
|
79
|
+
sha256: z.string(),
|
|
80
|
+
expiresAt: z.number().int().nonnegative(),
|
|
81
|
+
});
|
|
82
|
+
export type FileRedirectPayload = z.infer<typeof FileRedirectPayloadSchema>;
|
|
83
|
+
|
|
84
|
+
// Host-serve variant: the host asks the relay to mint a presigned PUT so it
|
|
85
|
+
// can stream a >256 KB file via S3 instead of inline WS chunks. `role` is set
|
|
86
|
+
// to `'host-serve'` to distinguish from peer-originated uploads (US-061).
|
|
87
|
+
export const FileUploadIntentPayloadSchema = z.object({
|
|
88
|
+
reqId: z.string(),
|
|
89
|
+
filename: z.string().min(1).max(255),
|
|
90
|
+
size: z.number().int().nonnegative().max(1_073_741_824),
|
|
91
|
+
contentType: z.string(),
|
|
92
|
+
nodeId: NodeIdSchema,
|
|
93
|
+
sha256: Sha256HexSchema,
|
|
94
|
+
role: z.enum(['host-serve', 'peer-upload']).optional(),
|
|
95
|
+
});
|
|
96
|
+
export type FileUploadIntentPayload = z.infer<typeof FileUploadIntentPayloadSchema>;
|
|
97
|
+
|
|
98
|
+
export const FileUploadDonePayloadSchema = z.object({
|
|
99
|
+
reqId: z.string(),
|
|
100
|
+
key: z.string(),
|
|
101
|
+
sha256: z.string(),
|
|
102
|
+
});
|
|
103
|
+
export type FileUploadDonePayload = z.infer<typeof FileUploadDonePayloadSchema>;
|
|
104
|
+
|
|
105
|
+
export const FilesManifestEntrySchema = z.object({
|
|
106
|
+
nodeId: z.string(),
|
|
107
|
+
relPath: z.string(),
|
|
108
|
+
size: z.number().int().nonnegative(),
|
|
109
|
+
etag: z.string(),
|
|
110
|
+
});
|
|
111
|
+
export type FilesManifestEntry = z.infer<typeof FilesManifestEntrySchema>;
|
|
112
|
+
|
|
113
|
+
export const FilesManifestPayloadSchema = z.object({
|
|
114
|
+
entries: z.array(FilesManifestEntrySchema),
|
|
115
|
+
});
|
|
116
|
+
export type FilesManifestPayload = z.infer<typeof FilesManifestPayloadSchema>;
|
|
117
|
+
|
|
118
|
+
export function parseEnvelope(
|
|
119
|
+
raw: unknown,
|
|
120
|
+
): { ok: true; envelope: Envelope } | { ok: false; reason: string } {
|
|
121
|
+
const result = EnvelopeSchema.safeParse(raw);
|
|
122
|
+
if (result.success) {
|
|
123
|
+
return { ok: true, envelope: result.data };
|
|
124
|
+
}
|
|
125
|
+
// Surface the first issue path/message so the transport's console.warn
|
|
126
|
+
// is actionable without leaking the payload itself.
|
|
127
|
+
const first = result.error.issues[0];
|
|
128
|
+
const reason = first ? `${first.path.join('.') || '<root>'}: ${first.message}` : 'invalid';
|
|
129
|
+
return { ok: false, reason };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface MakeEnvelopeOpts {
|
|
133
|
+
id?: string;
|
|
134
|
+
from?: string;
|
|
135
|
+
to?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function makeEnvelope<T extends EnvelopeType>(
|
|
139
|
+
type: T,
|
|
140
|
+
payload: unknown,
|
|
141
|
+
opts: MakeEnvelopeOpts = {},
|
|
142
|
+
): Envelope {
|
|
143
|
+
const env: Envelope = {
|
|
144
|
+
v: 1,
|
|
145
|
+
type,
|
|
146
|
+
from: opts.from ?? 'host',
|
|
147
|
+
payload,
|
|
148
|
+
};
|
|
149
|
+
if (opts.id !== undefined) env.id = opts.id;
|
|
150
|
+
if (opts.to !== undefined) env.to = opts.to;
|
|
151
|
+
return env;
|
|
152
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side `file-request` handler. The relay routes inbound `file-request`
|
|
3
|
+
* envelopes (peer -> host) here; the handler either responds inline with a
|
|
4
|
+
* single `file-bytes` frame (<=256 KB) or stages the file in the relay's
|
|
5
|
+
* ephemeral S3 bucket via `file-upload-intent` and replies with a
|
|
6
|
+
* `file-redirect` URL.
|
|
7
|
+
*
|
|
8
|
+
* Design choice: S3 staging is preferred over multi-chunk WS streaming for
|
|
9
|
+
* oversize files because the relay rate-limits `file-bytes` at 5 MB / 60 s per
|
|
10
|
+
* peer (US-058); large assets would otherwise stall. Staged objects live for
|
|
11
|
+
* <=60 s (the staging bucket has a 1-day lifecycle but presigned URLs expire
|
|
12
|
+
* in 60 s) so bytes never persist past the read.
|
|
13
|
+
*
|
|
14
|
+
* Errors reply with both a sentinel `file-bytes { eof:true, sha256:'' }` so
|
|
15
|
+
* the peer's `/files/<path>` proxy can return 4xx, AND an `rpc-result
|
|
16
|
+
* { id:reqId, ok:false, reason }` so peer rpc callers see a structured error.
|
|
17
|
+
*
|
|
18
|
+
* Rate-limit: at most 30 concurrent in-flight file-requests per peer; excess
|
|
19
|
+
* are rejected immediately with `reason: 'too-many-in-flight'`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import * as fsPromises from 'node:fs/promises';
|
|
24
|
+
import { basename, extname } from 'node:path';
|
|
25
|
+
import type { FlowEntry, Registry } from './registry.ts';
|
|
26
|
+
import {
|
|
27
|
+
type Envelope,
|
|
28
|
+
type FileBytesPayload,
|
|
29
|
+
type FileRedirectPayload,
|
|
30
|
+
FileRequestPayloadSchema,
|
|
31
|
+
type FileUploadIntentPayload,
|
|
32
|
+
makeEnvelope,
|
|
33
|
+
} from './share-envelope.ts';
|
|
34
|
+
import { resolveNodeFile } from './share-file-resolver.ts';
|
|
35
|
+
|
|
36
|
+
const INLINE_LIMIT_BYTES = 256 * 1024;
|
|
37
|
+
const MAX_INFLIGHT_PER_PEER = 30;
|
|
38
|
+
|
|
39
|
+
// Extension -> content type allowlist. Anything not in the map falls back to
|
|
40
|
+
// `application/octet-stream`. Lowercased keys; we lowercase the extension at
|
|
41
|
+
// lookup so `.PNG` resolves the same as `.png`.
|
|
42
|
+
const CONTENT_TYPE_MAP: Record<string, string> = {
|
|
43
|
+
'.png': 'image/png',
|
|
44
|
+
'.jpg': 'image/jpeg',
|
|
45
|
+
'.jpeg': 'image/jpeg',
|
|
46
|
+
'.webp': 'image/webp',
|
|
47
|
+
'.svg': 'image/svg+xml',
|
|
48
|
+
'.gif': 'image/gif',
|
|
49
|
+
'.html': 'text/html',
|
|
50
|
+
'.htm': 'text/html',
|
|
51
|
+
'.md': 'text/markdown',
|
|
52
|
+
'.txt': 'text/plain',
|
|
53
|
+
'.json': 'application/json',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function contentTypeFor(filename: string): string {
|
|
57
|
+
const ext = extname(filename).toLowerCase();
|
|
58
|
+
return CONTENT_TYPE_MAP[ext] ?? 'application/octet-stream';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UploadIntentReply {
|
|
62
|
+
// `getUrl`+`expiresAt` are returned alongside `putUrl` so the host can
|
|
63
|
+
// immediately reply with `file-redirect` after the PUT succeeds. The cloud
|
|
64
|
+
// relay currently embeds only `putUrl` (US-058); the wiring layer is
|
|
65
|
+
// responsible for materialising matching getUrl/expiresAt either by
|
|
66
|
+
// re-using the staging key with a second presigner call or by extending the
|
|
67
|
+
// relay reply. Tests inject this dep wholesale.
|
|
68
|
+
putUrl: string;
|
|
69
|
+
getUrl: string;
|
|
70
|
+
expiresAt: number;
|
|
71
|
+
key: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type RequestUploadIntent = (payload: {
|
|
75
|
+
reqId: string;
|
|
76
|
+
filename: string;
|
|
77
|
+
size: number;
|
|
78
|
+
contentType: string;
|
|
79
|
+
sha256: string;
|
|
80
|
+
nodeId: string;
|
|
81
|
+
role: 'host-serve';
|
|
82
|
+
}) => Promise<UploadIntentReply>;
|
|
83
|
+
|
|
84
|
+
export type PutToS3 = (
|
|
85
|
+
url: string,
|
|
86
|
+
bytes: Buffer,
|
|
87
|
+
contentType: string,
|
|
88
|
+
) => Promise<{ ok: boolean; status: number }>;
|
|
89
|
+
|
|
90
|
+
export interface FileRequestDeps {
|
|
91
|
+
registry: Registry;
|
|
92
|
+
// Sends outbound envelopes through the active transport. Defaults wire to
|
|
93
|
+
// share.ts's existing `broadcast` closure when integrated.
|
|
94
|
+
broadcast: (envelope: Envelope) => void;
|
|
95
|
+
// S3 staging path. Both deps must be provided to enable the oversize path;
|
|
96
|
+
// if either is absent, oversize requests reply with `reason: 'too-large'`
|
|
97
|
+
// and the peer falls back to a 413.
|
|
98
|
+
requestUploadIntent?: RequestUploadIntent;
|
|
99
|
+
putToS3?: PutToS3;
|
|
100
|
+
// Test overrides — production callers leave these on the node:fs defaults.
|
|
101
|
+
readFile?: (p: string) => Promise<Buffer>;
|
|
102
|
+
fileExists?: (p: string) => Promise<boolean>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FileRequestHandler {
|
|
106
|
+
handle(envelope: Envelope): Promise<void>;
|
|
107
|
+
// Exposed for tests so they can assert the in-flight counter is released
|
|
108
|
+
// after each request settles.
|
|
109
|
+
inflightCount(peerConnId: string): number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const defaultFileExists = async (p: string): Promise<boolean> => {
|
|
113
|
+
try {
|
|
114
|
+
const s = await fsPromises.stat(p);
|
|
115
|
+
return s.isFile();
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const defaultReadFile = (p: string): Promise<Buffer> => fsPromises.readFile(p) as Promise<Buffer>;
|
|
122
|
+
|
|
123
|
+
// Iterate all registered flow entries and return the first whose resolved
|
|
124
|
+
// per-node path exists on disk. nodeIds are globally unique (10-char base62
|
|
125
|
+
// shortIds) so collisions across flows are astronomically unlikely; the
|
|
126
|
+
// existence probe disambiguates if it ever happens.
|
|
127
|
+
type LocateResult =
|
|
128
|
+
| { kind: 'ok'; entry: FlowEntry; absPath: string }
|
|
129
|
+
| { kind: 'bad-node-id' }
|
|
130
|
+
| { kind: 'traversal' }
|
|
131
|
+
| { kind: 'not-found' };
|
|
132
|
+
|
|
133
|
+
async function locateNodeFile(
|
|
134
|
+
registry: Registry,
|
|
135
|
+
nodeId: string,
|
|
136
|
+
relPath: string,
|
|
137
|
+
fileExists: (p: string) => Promise<boolean>,
|
|
138
|
+
): Promise<LocateResult> {
|
|
139
|
+
const entries = registry.list();
|
|
140
|
+
if (entries.length === 0) return { kind: 'not-found' };
|
|
141
|
+
|
|
142
|
+
// Run the resolver against the first entry to surface the static-shape
|
|
143
|
+
// errors (bad nodeId / traversal). These depend only on nodeId+relPath, so
|
|
144
|
+
// any registry entry yields the same verdict.
|
|
145
|
+
const first = entries[0];
|
|
146
|
+
if (first) {
|
|
147
|
+
const r = resolveNodeFile({
|
|
148
|
+
repoPath: first.repoPath,
|
|
149
|
+
flowPath: first.flowPath,
|
|
150
|
+
nodeId,
|
|
151
|
+
relPath,
|
|
152
|
+
});
|
|
153
|
+
if ('error' in r) {
|
|
154
|
+
if (r.error === 'bad-node-id') return { kind: 'bad-node-id' };
|
|
155
|
+
return { kind: 'traversal' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
const r = resolveNodeFile({
|
|
161
|
+
repoPath: entry.repoPath,
|
|
162
|
+
flowPath: entry.flowPath,
|
|
163
|
+
nodeId,
|
|
164
|
+
relPath,
|
|
165
|
+
});
|
|
166
|
+
if ('error' in r) continue;
|
|
167
|
+
if (await fileExists(r.absPath)) {
|
|
168
|
+
return { kind: 'ok', entry, absPath: r.absPath };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { kind: 'not-found' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createFileRequestHandler(deps: FileRequestDeps): FileRequestHandler {
|
|
175
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
176
|
+
const fileExists = deps.fileExists ?? defaultFileExists;
|
|
177
|
+
const inflightPerPeer = new Map<string, number>();
|
|
178
|
+
|
|
179
|
+
const sendErrorReply = (replyTo: string, reqId: string, reason: string) => {
|
|
180
|
+
// Send the sentinel file-bytes so the peer's /files proxy returns 4xx,
|
|
181
|
+
// then the structured rpc-result with the actual reason. Order matters
|
|
182
|
+
// for the peer-side state machine in US-063 (proxy resolves on the
|
|
183
|
+
// file-bytes sentinel, rpc callers resolve on the rpc-result).
|
|
184
|
+
const sentinel: FileBytesPayload = {
|
|
185
|
+
reqId,
|
|
186
|
+
seq: 0,
|
|
187
|
+
total: 0,
|
|
188
|
+
base64: '',
|
|
189
|
+
sha256: '',
|
|
190
|
+
eof: true,
|
|
191
|
+
};
|
|
192
|
+
deps.broadcast(makeEnvelope('file-bytes', sentinel, { to: replyTo }));
|
|
193
|
+
deps.broadcast(makeEnvelope('rpc-result', { ok: false, reason }, { to: replyTo, id: reqId }));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const respondInline = (
|
|
197
|
+
replyTo: string,
|
|
198
|
+
reqId: string,
|
|
199
|
+
bytes: Buffer,
|
|
200
|
+
contentType: string,
|
|
201
|
+
sha256: string,
|
|
202
|
+
) => {
|
|
203
|
+
const payload: FileBytesPayload = {
|
|
204
|
+
reqId,
|
|
205
|
+
seq: 0,
|
|
206
|
+
total: 1,
|
|
207
|
+
base64: bytes.toString('base64'),
|
|
208
|
+
contentType,
|
|
209
|
+
sha256,
|
|
210
|
+
eof: true,
|
|
211
|
+
};
|
|
212
|
+
deps.broadcast(makeEnvelope('file-bytes', payload, { to: replyTo }));
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const respondRedirect = (
|
|
216
|
+
replyTo: string,
|
|
217
|
+
reqId: string,
|
|
218
|
+
intent: UploadIntentReply,
|
|
219
|
+
sha256: string,
|
|
220
|
+
) => {
|
|
221
|
+
const payload: FileRedirectPayload = {
|
|
222
|
+
reqId,
|
|
223
|
+
getUrl: intent.getUrl,
|
|
224
|
+
sha256,
|
|
225
|
+
expiresAt: intent.expiresAt,
|
|
226
|
+
};
|
|
227
|
+
deps.broadcast(makeEnvelope('file-redirect', payload, { to: replyTo }));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handle = async (envelope: Envelope): Promise<void> => {
|
|
231
|
+
const replyTo = envelope.from;
|
|
232
|
+
const parsed = FileRequestPayloadSchema.safeParse(envelope.payload);
|
|
233
|
+
if (!parsed.success) {
|
|
234
|
+
// We can't address the rpc-result without a reqId. Pull a best-effort id
|
|
235
|
+
// from the raw payload (relay never inspects it, so anything goes) and
|
|
236
|
+
// fall back to a literal `'invalid'` so the wire schema's min(1) holds.
|
|
237
|
+
const rawReqId =
|
|
238
|
+
envelope.payload &&
|
|
239
|
+
typeof envelope.payload === 'object' &&
|
|
240
|
+
'reqId' in envelope.payload &&
|
|
241
|
+
typeof (envelope.payload as { reqId?: unknown }).reqId === 'string' &&
|
|
242
|
+
(envelope.payload as { reqId: string }).reqId.length > 0
|
|
243
|
+
? (envelope.payload as { reqId: string }).reqId
|
|
244
|
+
: 'invalid';
|
|
245
|
+
sendErrorReply(replyTo, rawReqId, 'bad-payload');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const { reqId, nodeId, relPath } = parsed.data;
|
|
249
|
+
|
|
250
|
+
const current = inflightPerPeer.get(replyTo) ?? 0;
|
|
251
|
+
if (current >= MAX_INFLIGHT_PER_PEER) {
|
|
252
|
+
sendErrorReply(replyTo, reqId, 'too-many-in-flight');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
inflightPerPeer.set(replyTo, current + 1);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const located = await locateNodeFile(deps.registry, nodeId, relPath, fileExists);
|
|
259
|
+
if (located.kind === 'bad-node-id') {
|
|
260
|
+
sendErrorReply(replyTo, reqId, 'bad-node-id');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (located.kind === 'traversal') {
|
|
264
|
+
sendErrorReply(replyTo, reqId, 'traversal');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (located.kind === 'not-found') {
|
|
268
|
+
sendErrorReply(replyTo, reqId, 'not-found');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let bytes: Buffer;
|
|
273
|
+
try {
|
|
274
|
+
bytes = await readFile(located.absPath);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.warn('[share] file-request read failed:', {
|
|
277
|
+
nodeId,
|
|
278
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
279
|
+
});
|
|
280
|
+
sendErrorReply(replyTo, reqId, 'read-failed');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
285
|
+
const filename = basename(located.absPath);
|
|
286
|
+
const contentType = contentTypeFor(filename);
|
|
287
|
+
|
|
288
|
+
if (bytes.length <= INLINE_LIMIT_BYTES) {
|
|
289
|
+
respondInline(replyTo, reqId, bytes, contentType, sha256);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Oversize path: stage to S3 and reply with redirect.
|
|
294
|
+
if (!deps.requestUploadIntent || !deps.putToS3) {
|
|
295
|
+
sendErrorReply(replyTo, reqId, 'too-large');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const intentPayload: FileUploadIntentPayload = {
|
|
299
|
+
reqId,
|
|
300
|
+
filename,
|
|
301
|
+
size: bytes.length,
|
|
302
|
+
contentType,
|
|
303
|
+
sha256,
|
|
304
|
+
nodeId,
|
|
305
|
+
role: 'host-serve',
|
|
306
|
+
};
|
|
307
|
+
let intent: UploadIntentReply;
|
|
308
|
+
try {
|
|
309
|
+
intent = await deps.requestUploadIntent({
|
|
310
|
+
reqId: intentPayload.reqId,
|
|
311
|
+
filename: intentPayload.filename,
|
|
312
|
+
size: intentPayload.size,
|
|
313
|
+
contentType: intentPayload.contentType,
|
|
314
|
+
sha256: intentPayload.sha256,
|
|
315
|
+
nodeId: intentPayload.nodeId,
|
|
316
|
+
role: 'host-serve',
|
|
317
|
+
});
|
|
318
|
+
} catch (err) {
|
|
319
|
+
console.warn('[share] file-request intent failed:', {
|
|
320
|
+
nodeId,
|
|
321
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
322
|
+
});
|
|
323
|
+
sendErrorReply(replyTo, reqId, 'intent-failed');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
let putResult: { ok: boolean; status: number };
|
|
327
|
+
try {
|
|
328
|
+
putResult = await deps.putToS3(intent.putUrl, bytes, contentType);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.warn('[share] file-request s3 put threw:', {
|
|
331
|
+
nodeId,
|
|
332
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
333
|
+
});
|
|
334
|
+
sendErrorReply(replyTo, reqId, 'put-failed');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!putResult.ok) {
|
|
338
|
+
sendErrorReply(replyTo, reqId, `put-status-${putResult.status}`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
respondRedirect(replyTo, reqId, intent, sha256);
|
|
342
|
+
} finally {
|
|
343
|
+
const next = (inflightPerPeer.get(replyTo) ?? 1) - 1;
|
|
344
|
+
if (next <= 0) inflightPerPeer.delete(replyTo);
|
|
345
|
+
else inflightPerPeer.set(replyTo, next);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
handle,
|
|
351
|
+
inflightCount: (peerConnId) => inflightPerPeer.get(peerConnId) ?? 0,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
|
|
3
|
+
export type ResolveError = 'bad-node-id' | 'traversal' | 'outside-flow-dir';
|
|
4
|
+
|
|
5
|
+
export type ResolveNodeFileResult = { absPath: string } | { error: ResolveError };
|
|
6
|
+
|
|
7
|
+
export interface ResolveNodeFileOptions {
|
|
8
|
+
repoPath: string;
|
|
9
|
+
flowPath: string;
|
|
10
|
+
nodeId: string;
|
|
11
|
+
relPath: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NODE_ID_RE = /^node-[A-Za-z0-9]{10}$/;
|
|
15
|
+
|
|
16
|
+
// Map a peer-supplied `{ nodeId, relPath }` request into a guaranteed-safe
|
|
17
|
+
// absolute path under `<repoPath>/<dirname(flowPath)>/nodes/<nodeId>/`. Mirrors
|
|
18
|
+
// the per-node upload sink at api.ts /projects/.../nodes/:nodeId/files/upload:
|
|
19
|
+
// for manifest-driven projects the base dir is `<flowDir>/nodes/<id>/`; for
|
|
20
|
+
// legacy single-flow registrations (flow.json at the project root, flowDir==='.')
|
|
21
|
+
// it collapses to `<repoPath>/nodes/<id>/`. Refuses any input that escapes that
|
|
22
|
+
// per-node scope.
|
|
23
|
+
export const resolveNodeFile = (
|
|
24
|
+
opts: ResolveNodeFileOptions,
|
|
25
|
+
pathMod: typeof nodePath = nodePath,
|
|
26
|
+
): ResolveNodeFileResult => {
|
|
27
|
+
const { repoPath, flowPath, nodeId, relPath } = opts;
|
|
28
|
+
|
|
29
|
+
if (!NODE_ID_RE.test(nodeId)) {
|
|
30
|
+
return { error: 'bad-node-id' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof relPath !== 'string' || relPath.length === 0) {
|
|
34
|
+
return { error: 'traversal' };
|
|
35
|
+
}
|
|
36
|
+
// Refuse absolute paths in either separator style so a host running on POSIX
|
|
37
|
+
// still rejects a `C:\…` or `\\server\share` payload from a win32 peer (and
|
|
38
|
+
// vice-versa). Win32 absoluteness covers drive letters and UNC paths.
|
|
39
|
+
if (relPath.startsWith('/') || relPath.startsWith('\\')) {
|
|
40
|
+
return { error: 'traversal' };
|
|
41
|
+
}
|
|
42
|
+
if (pathMod.isAbsolute(relPath) || nodePath.win32.isAbsolute(relPath)) {
|
|
43
|
+
return { error: 'traversal' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// After normalize, any remaining `..` segment means the relPath tries to
|
|
47
|
+
// escape its node folder. Split on BOTH separators so a posix host catches
|
|
48
|
+
// `..\\evil` and a win32 host catches `../evil`.
|
|
49
|
+
const normalized = pathMod.normalize(relPath);
|
|
50
|
+
const segments = normalized.split(/[/\\]/);
|
|
51
|
+
if (segments.some((segment) => segment === '..')) {
|
|
52
|
+
return { error: 'traversal' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const flowDir = pathMod.dirname(flowPath);
|
|
56
|
+
const baseDir =
|
|
57
|
+
flowDir === '.'
|
|
58
|
+
? pathMod.join(repoPath, 'nodes', nodeId)
|
|
59
|
+
: pathMod.join(repoPath, flowDir, 'nodes', nodeId);
|
|
60
|
+
|
|
61
|
+
const absPath = pathMod.resolve(baseDir, relPath);
|
|
62
|
+
|
|
63
|
+
if (absPath !== baseDir && !absPath.startsWith(baseDir + pathMod.sep)) {
|
|
64
|
+
return { error: 'outside-flow-dir' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { absPath };
|
|
68
|
+
};
|