@elefunc/send 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elefunc, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @elefunc/send
2
+
3
+ Browser-compatible file transfer CLI and TUI built with Bun, WebRTC, and Rezi.
4
+
5
+ ## Requirements
6
+
7
+ - Bun `>= 1.3.11`
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ bun add -g @elefunc/send
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ send peers
19
+ send offer ./file.txt
20
+ send accept
21
+ send tui --events
22
+ ```
23
+
24
+ ## Rooms
25
+
26
+ `--room` is optional on all commands. If you omit it, `send` creates a random room and prints or shows it.
27
+
28
+ ## Self Identity
29
+
30
+ `--self` accepts three forms:
31
+
32
+ - `name`
33
+ - `name-ID`
34
+ - `-ID` using the attached CLI form `--self=-ab12cd34`
35
+
36
+ `SEND_SELF` supports the same raw values, including `SEND_SELF=-ab12cd34`.
37
+
38
+ The ID suffix must be exactly 8 lowercase alphanumeric characters.
39
+
40
+ ## Examples
41
+
42
+ ```bash
43
+ send peers --self alice
44
+ send offer ./demo.txt --self alice-ab12cd34
45
+ send accept --self=-ab12cd34
46
+ SEND_SELF=-ab12cd34 send tui
47
+ ```
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ bun install
53
+ bun run typecheck
54
+ bun test
55
+ ```
56
+
57
+ The package is Bun-native and keeps its runtime patches in `patches/`.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@elefunc/send",
3
+ "version": "0.1.0",
4
+ "description": "Browser-compatible file transfer CLI and TUI powered by Bun, WebRTC, and Rezi.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "packageManager": "bun@1.3.11",
8
+ "engines": {
9
+ "bun": ">=1.3.11"
10
+ },
11
+ "bin": {
12
+ "send": "./src/index.ts"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "patches",
17
+ "README.md",
18
+ "LICENSE",
19
+ "package.json"
20
+ ],
21
+ "scripts": {
22
+ "start": "bun run ./src/index.ts",
23
+ "test": "bun test",
24
+ "fix:rezi": "bun run ./dia/rezi-fix.ts",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/elefunc/send.git"
30
+ },
31
+ "homepage": "https://github.com/elefunc/send#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/elefunc/send/issues"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@rezi-ui/core": "0.1.0-alpha.60",
40
+ "@rezi-ui/node": "0.1.0-alpha.60",
41
+ "cac": "^7.0.0",
42
+ "werift": "^0.22.9"
43
+ },
44
+ "patchedDependencies": {
45
+ "@rezi-ui/core@0.1.0-alpha.60": "patches/@rezi-ui%2Fcore@0.1.0-alpha.60.patch",
46
+ "werift@0.22.9": "patches/werift@0.22.9.patch"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^24.5.2",
50
+ "typescript": "^5.8.3"
51
+ }
52
+ }
@@ -0,0 +1,227 @@
1
+ diff --git a/dist/layout/engine/intrinsic.js b/dist/layout/engine/intrinsic.js
2
+ index 8df3d41..d29496d 100644
3
+ --- a/dist/layout/engine/intrinsic.js
4
+ +++ b/dist/layout/engine/intrinsic.js
5
+ @@ -168,7 +168,7 @@ function measureLeafMaxContent(vnode, axis, measureNode) {
6
+ const placeholder = typeof placeholderRaw === "string" ? placeholderRaw : "";
7
+ const content = propsRes.value.value.length > 0 ? propsRes.value.value : placeholder;
8
+ const textW = measureTextCells(content);
9
+ - return ok(clampSize({ w: textW + 2, h: 1 }));
10
+ + return ok(clampSize({ w: textW + 3, h: 1 }));
11
+ }
12
+ case "progress": {
13
+ const props = vnode.props;
14
+ @@ -388,4 +388,4 @@ export function measureMaxContent(vnode, axis, measureNode) {
15
+ return measureLeafMaxContent(vnode, axis, measureNode);
16
+ }
17
+ }
18
+ -//# sourceMappingURL=intrinsic.js.map
19
+
20
+ +//# sourceMappingURL=intrinsic.js.map
21
+ diff --git a/dist/layout/kinds/leaf.js b/dist/layout/kinds/leaf.js
22
+ index 255750a..4daf5da 100644
23
+ --- a/dist/layout/kinds/leaf.js
24
+ +++ b/dist/layout/kinds/leaf.js
25
+ @@ -71,7 +71,7 @@ export function measureLeaf(vnode, maxW, maxH, axis) {
26
+ const placeholder = typeof placeholderRaw === "string" ? placeholderRaw : "";
27
+ const content = propsRes.value.value.length > 0 ? propsRes.value.value : placeholder;
28
+ const textW = measureTextCells(content);
29
+ - const w = Math.min(maxW, textW + 2);
30
+ + const w = Math.min(maxW, textW + 3);
31
+ const h = Math.min(maxH, 1);
32
+ return ok({ w, h });
33
+ }
34
+ @@ -533,4 +533,4 @@ export function layoutLeafKind(vnode, x, y, rectW, rectH) {
35
+ };
36
+ }
37
+ }
38
+ -//# sourceMappingURL=leaf.js.map
39
+
40
+ +//# sourceMappingURL=leaf.js.map
41
+ diff --git a/dist/layout/kinds/box.js b/dist/layout/kinds/box.js
42
+ index dca103c..14f2973 100644
43
+ --- a/dist/layout/kinds/box.js
44
+ +++ b/dist/layout/kinds/box.js
45
+ @@ -4,6 +4,7 @@ import { childHasAbsolutePosition } from "../engine/guards.js";
46
+ import { ok } from "../engine/result.js";
47
+ import { resolveMargin as resolveMarginProps, resolveSpacing as resolveSpacingProps, } from "../spacing.js";
48
+ import { validateBoxProps } from "../validateProps.js";
49
+ +const OVERFLOW_CONTENT_LIMIT = 2147483647;
50
+ const syntheticColumnCache = new WeakMap();
51
+ function computeFlowSignature(children) {
52
+ let signature = `${children.length}:`;
53
+ @@ -88,12 +89,13 @@ export function measureBoxKinds(vnode, maxW, maxH, axis, measureNode) {
54
+ const outerHLimit = forcedH ?? maxHCap;
55
+ const cw = clampNonNegative(outerWLimit - bl - br - spacing.left - spacing.right);
56
+ const ch = clampNonNegative(outerHLimit - bt - bb - spacing.top - spacing.bottom);
57
+ + const flowMeasureH = propsRes.value.overflow === "scroll" ? OVERFLOW_CONTENT_LIMIT : ch;
58
+ // Children are laid out as a Column inside the content rect.
59
+ let contentUsedW = 0;
60
+ let contentUsedH = 0;
61
+ if (vnode.children.length > 0) {
62
+ const columnNode = getSyntheticColumn(vnode, propsRes.value.gap);
63
+ - const innerRes = measureNode(columnNode, cw, ch, "column");
64
+ + const innerRes = measureNode(columnNode, cw, flowMeasureH, "column");
65
+ if (!innerRes.ok)
66
+ return innerRes;
67
+ contentUsedW = innerRes.value.w;
68
+ @@ -154,9 +156,16 @@ export function layoutBoxKinds(vnode, x, y, rectW, rectH, axis, measureNode, lay
69
+ const children = [];
70
+ if (vnode.children.length > 0) {
71
+ const columnNode = getSyntheticColumn(vnode, propsRes.value.gap);
72
+ + const flowMeasureH = propsRes.value.overflow === "scroll" ? OVERFLOW_CONTENT_LIMIT : ch;
73
+ + const flowMeasureRes = measureNode(columnNode, cw, flowMeasureH, "column");
74
+ + if (!flowMeasureRes.ok)
75
+ + return flowMeasureRes;
76
+ + const flowLayoutH = propsRes.value.overflow === "scroll"
77
+ + ? Math.max(ch, flowMeasureRes.value.h)
78
+ + : ch;
79
+ // The synthetic column wrapper must fill the box content rect so that
80
+ // percentage constraints resolve against the actual available space.
81
+ - const innerRes = layoutNode(columnNode, cx, cy, cw, ch, "column", cw, ch);
82
+ + const innerRes = layoutNode(columnNode, cx, cy, cw, flowLayoutH, "column", cw, flowLayoutH, flowMeasureRes.value);
83
+ if (!innerRes.ok)
84
+ return innerRes;
85
+ // Attach the box's children (not the synthetic column wrapper).
86
+ @@ -234,4 +243,4 @@ export function layoutBoxKinds(vnode, x, y, rectW, rectH, axis, measureNode, lay
87
+ };
88
+ }
89
+ }
90
+ -//# sourceMappingURL=box.js.map
91
+
92
+ +//# sourceMappingURL=box.js.map
93
+ diff --git a/dist/app/widgetRenderer.js b/dist/app/widgetRenderer.js
94
+ index 38d4a5f..69ee099 100644
95
+ --- a/dist/app/widgetRenderer.js
96
+ +++ b/dist/app/widgetRenderer.js
97
+ @@ -614,6 +614,7 @@ export class WidgetRenderer {
98
+ tableStore = createTableStateStore();
99
+ treeStore = createTreeStateStore();
100
+ scrollOverrides = new Map();
101
+ + hasPendingScrollOverride = false;
102
+ /* --- Tree Lazy-Loading Cache (per tree id, per node key) --- */
103
+ loadedTreeChildrenByTreeId = new Map();
104
+ treeLoadTokenByTreeAndKey = new Map();
105
+ @@ -1472,6 +1473,9 @@ export class WidgetRenderer {
106
+ diffViewerById: this.diffViewerById,
107
+ rectById: this.rectById,
108
+ scrollOverrides: this.scrollOverrides,
109
+ + markScrollOverrideDirty: () => {
110
+ + this.hasPendingScrollOverride = true;
111
+ + },
112
+ findScrollableAncestors: (targetId) => this.findScrollableAncestors(targetId),
113
+ });
114
+ if (wheelRoute)
115
+ @@ -1750,6 +1754,53 @@ export class WidgetRenderer {
116
+ }
117
+ return Object.freeze([]);
118
+ }
119
+ + syncScrollOverridesFromLayoutTree() {
120
+ + if (!this.committedRoot || !this.layoutTree) {
121
+ + this.scrollOverrides.clear();
122
+ + return;
123
+ + }
124
+ + const nextOverrides = new Map();
125
+ + const stack = [
126
+ + {
127
+ + runtimeNode: this.committedRoot,
128
+ + layoutNode: this.layoutTree,
129
+ + },
130
+ + ];
131
+ + while (stack.length > 0) {
132
+ + const frame = stack.pop();
133
+ + if (!frame)
134
+ + continue;
135
+ + const runtimeNode = frame.runtimeNode;
136
+ + const layoutNode = frame.layoutNode;
137
+ + const props = runtimeNode.vnode.props;
138
+ + const nodeId = typeof props.id === "string" && props.id.length > 0 ? props.id : null;
139
+ + if (nodeId !== null && props.overflow === "scroll" && layoutNode.meta) {
140
+ + const meta = layoutNode.meta;
141
+ + const hasScrollableAxis = meta.contentWidth > meta.viewportWidth || meta.contentHeight > meta.viewportHeight;
142
+ + if (hasScrollableAxis) {
143
+ + nextOverrides.set(nodeId, Object.freeze({
144
+ + scrollX: meta.scrollX,
145
+ + scrollY: meta.scrollY,
146
+ + }));
147
+ + }
148
+ + }
149
+ + const childCount = Math.min(runtimeNode.children.length, layoutNode.children.length);
150
+ + for (let i = childCount - 1; i >= 0; i--) {
151
+ + const runtimeChild = runtimeNode.children[i];
152
+ + const layoutChild = layoutNode.children[i];
153
+ + if (!runtimeChild || !layoutChild)
154
+ + continue;
155
+ + stack.push({
156
+ + runtimeNode: runtimeChild,
157
+ + layoutNode: layoutChild,
158
+ + });
159
+ + }
160
+ + }
161
+ + this.scrollOverrides.clear();
162
+ + for (const [nodeId, override] of nextOverrides) {
163
+ + this.scrollOverrides.set(nodeId, override);
164
+ + }
165
+ + }
166
+ applyScrollOverridesToVNode(vnode, overrides = this
167
+ .scrollOverrides) {
168
+ const propsRecord = (vnode.props ?? {});
169
+ @@ -1763,7 +1814,7 @@ export class WidgetRenderer {
170
+ nextPropsMutable = { ...propsRecord };
171
+ return nextPropsMutable;
172
+ };
173
+ - if (override) {
174
+ + if (override && propsForRead.overflow === "scroll") {
175
+ if (propsForRead.scrollX !== override.scrollX || propsForRead.scrollY !== override.scrollY) {
176
+ const mutable = ensureMutableProps();
177
+ mutable.scrollX = override.scrollX;
178
+ @@ -2643,7 +2694,7 @@ export class WidgetRenderer {
179
+ // layout when explicitly requested (resize/layout dirty), bootstrap lacks
180
+ // a tree, or committed layout signatures changed.
181
+ let doLayout = plan.layout || this.layoutTree === null;
182
+ - if (this.scrollOverrides.size > 0)
183
+ + if (this.hasPendingScrollOverride)
184
+ doLayout = true;
185
+ const frameNowMs = typeof plan.nowMs === "number" && Number.isFinite(plan.nowMs)
186
+ ? plan.nowMs
187
+ @@ -2916,7 +2967,7 @@ export class WidgetRenderer {
188
+ const layoutRootVNode = pendingScrollOverrides !== null
189
+ ? this.applyScrollOverridesToVNode(constrainedLayoutRootVNode, pendingScrollOverrides)
190
+ : constrainedLayoutRootVNode;
191
+ - this.scrollOverrides.clear();
192
+ + this.hasPendingScrollOverride = false;
193
+ const initialLayoutRes = this.layoutWithShapeFallback(layoutRootVNode, constrainedLayoutRootVNode, rootPad, rootW, rootH, true);
194
+ if (!initialLayoutRes.ok) {
195
+ perfMarkEnd("layout", layoutToken);
196
+ @@ -3001,6 +3052,7 @@ export class WidgetRenderer {
197
+ }
198
+ perfMarkEnd("layout", layoutToken);
199
+ this.layoutTree = nextLayoutTree;
200
+ + this.syncScrollOverridesFromLayoutTree();
201
+ if (doCommit) {
202
+ // Seed/refresh per-instance layout stability signatures after a real
203
+ // layout pass so subsequent commits can take the signature fast path.
204
+ @@ -4076,4 +4128,4 @@ export class WidgetRenderer {
205
+ }
206
+ }
207
+ }
208
+ -//# sourceMappingURL=widgetRenderer.js.map
209
+
210
+ +//# sourceMappingURL=widgetRenderer.js.map
211
+ diff --git a/dist/app/widgetRenderer/mouseRouting.js b/dist/app/widgetRenderer/mouseRouting.js
212
+ index d3b08cf..00b77a3 100644
213
+ --- a/dist/app/widgetRenderer/mouseRouting.js
214
+ +++ b/dist/app/widgetRenderer/mouseRouting.js
215
+ @@ -1205,9 +1205,10 @@ export function routeMouseWheel(event, ctx) {
216
+ scrollX: r.nextScrollX ?? meta.scrollX,
217
+ scrollY: r.nextScrollY ?? meta.scrollY,
218
+ });
219
+ + ctx.markScrollOverrideDirty?.();
220
+ return ROUTE_RENDER;
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+ -//# sourceMappingURL=mouseRouting.js.map
226
+
227
+ +//# sourceMappingURL=mouseRouting.js.map
@@ -0,0 +1,31 @@
1
+ diff --git a/lib/webrtc/src/transport/ice.js b/lib/webrtc/src/transport/ice.js
2
+ index 25c8489..cd06dbf 100644
3
+ --- a/lib/webrtc/src/transport/ice.js
4
+ +++ b/lib/webrtc/src/transport/ice.js
5
+ @@ -230,25 +230,25 @@ class RTCIceTransport {
6
+ stats.push(candidateStats);
7
+ }
8
+ // Candidate pairs
9
+ const pairs = this.connection?.candidatePairs
10
+ ? [
11
+ ...this.connection.candidatePairs.filter((p) => p.nominated),
12
+ ...this.connection.candidatePairs.filter((p) => !p.nominated),
13
+ ]
14
+ : [];
15
+ for (const pair of pairs) {
16
+ const pairStats = {
17
+ type: "candidate-pair",
18
+ - id: (0, stats_1.generateStatsId)("candidate-pair", pair.foundation),
19
+ + id: (0, stats_1.generateStatsId)("candidate-pair", pair.localCandidate.foundation, pair.remoteCandidate.foundation),
20
+ timestamp,
21
+ transportId: (0, stats_1.generateStatsId)("transport", this.id),
22
+ localCandidateId: (0, stats_1.generateStatsId)("local-candidate", pair.localCandidate.foundation),
23
+ remoteCandidateId: (0, stats_1.generateStatsId)("remote-candidate", pair.remoteCandidate.foundation),
24
+ state: pair.state,
25
+ nominated: pair.nominated,
26
+ packetsSent: pair.packetsSent,
27
+ packetsReceived: pair.packetsReceived,
28
+ bytesSent: pair.bytesSent,
29
+ bytesReceived: pair.bytesReceived,
30
+ currentRoundTripTime: pair.rtt,
31
+ };
@@ -0,0 +1,79 @@
1
+ import { access, mkdir, stat } from "node:fs/promises"
2
+ import { basename, extname, join, resolve } from "node:path"
3
+ import { resolveUserPath } from "./paths"
4
+
5
+ export interface LocalFile {
6
+ path: string
7
+ name: string
8
+ size: number
9
+ type: string
10
+ lastModified: number
11
+ blob: Blob
12
+ }
13
+
14
+ export type LocalFileInfo = Omit<LocalFile, "blob">
15
+ export interface LocalPathIssue {
16
+ path: string
17
+ error: string
18
+ }
19
+
20
+ const exists = async (path: string) => access(path).then(() => true, () => false)
21
+
22
+ export const inspectLocalFile = async (path: string): Promise<LocalFileInfo> => {
23
+ const absolute = resolveUserPath(path)
24
+ const info = await stat(absolute)
25
+ if (!info.isFile()) throw new Error(`not a file: ${absolute}`)
26
+ const blob = Bun.file(absolute)
27
+ return {
28
+ path: absolute,
29
+ name: basename(absolute),
30
+ size: info.size,
31
+ type: blob.type || "application/octet-stream",
32
+ lastModified: Math.round(info.mtimeMs || Date.now()),
33
+ }
34
+ }
35
+
36
+ export const inspectLocalPaths = async (paths: string[]) => {
37
+ const results = await Promise.allSettled(paths.map(inspectLocalFile))
38
+ const files: LocalFileInfo[] = []
39
+ const errors: LocalPathIssue[] = []
40
+ results.forEach((result, index) => {
41
+ if (result.status === "fulfilled") {
42
+ files.push(result.value)
43
+ return
44
+ }
45
+ errors.push({
46
+ path: resolveUserPath(paths[index] || ""),
47
+ error: result.reason instanceof Error ? result.reason.message : `${result.reason}`,
48
+ })
49
+ })
50
+ return { files, errors }
51
+ }
52
+
53
+ export const loadLocalFile = async (path: string): Promise<LocalFile> => {
54
+ const info = await inspectLocalFile(path)
55
+ return {
56
+ ...info,
57
+ blob: Bun.file(info.path),
58
+ }
59
+ }
60
+
61
+ export const loadLocalFiles = (paths: string[]) => Promise.all(paths.map(loadLocalFile))
62
+
63
+ export const readFileChunk = async (file: LocalFile, offset: number, size: number) => Buffer.from(await file.blob.slice(offset, offset + size).arrayBuffer())
64
+
65
+ export const uniqueOutputPath = async (directory: string, fileName: string) => {
66
+ await mkdir(directory, { recursive: true })
67
+ const extension = extname(fileName)
68
+ const stem = extension ? fileName.slice(0, -extension.length) : fileName
69
+ for (let index = 0; ; index += 1) {
70
+ const candidate = join(directory, index ? `${stem} (${index})${extension}` : fileName)
71
+ if (!await exists(candidate)) return candidate
72
+ }
73
+ }
74
+
75
+ export const saveIncomingFile = async (directory: string, fileName: string, data: Buffer) => {
76
+ const path = await uniqueOutputPath(directory, fileName)
77
+ await Bun.write(path, data)
78
+ return path
79
+ }
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os"
2
+ import { resolve } from "node:path"
3
+
4
+ const normalizePathInput = (value: string) => value.replace(/\\/gu, "/")
5
+
6
+ export const isHomeDirectoryPath = (value: string) => {
7
+ const normalized = normalizePathInput(value)
8
+ return normalized === "~" || normalized.startsWith("~/")
9
+ }
10
+
11
+ export const expandHomePath = (value: string, home = homedir()) => {
12
+ const normalized = normalizePathInput(value)
13
+ if (normalized === "~") return home
14
+ if (normalized.startsWith("~/")) return resolve(home, normalized.slice(2))
15
+ return null
16
+ }
17
+
18
+ export const resolveUserPath = (value: string, cwd = process.cwd(), home = homedir()) =>
19
+ expandHomePath(value, home) ?? resolve(cwd, value)