hop2 0.8.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/.hop-tmux.conf +18 -0
- package/INTEGRATION.md +92 -0
- package/README.md +260 -0
- package/hay/CHANGELOG.md +46 -0
- package/hay/DEV_NOTES.md +39 -0
- package/hay/README.md +290 -0
- package/hay/apps/cli/package.json +25 -0
- package/hay/apps/cli/src/index.ts +1250 -0
- package/hay/apps/cli/tsconfig.json +8 -0
- package/hay/apps/server/package.json +37 -0
- package/hay/apps/server/src/index.ts +103 -0
- package/hay/apps/server/src/lib.ts +161 -0
- package/hay/apps/server/src/pty.ts +68 -0
- package/hay/apps/server/src/rooms.ts +421 -0
- package/hay/apps/server/src/utils.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +146 -0
- package/hay/apps/server/tsconfig.json +9 -0
- package/hay/apps/web/index.html +12 -0
- package/hay/apps/web/package.json +32 -0
- package/hay/apps/web/scripts/run-e2e.mjs +130 -0
- package/hay/apps/web/src/App.tsx +1302 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
- package/hay/apps/web/src/main.tsx +13 -0
- package/hay/apps/web/src/setupTests.ts +1 -0
- package/hay/apps/web/src/styles.css +1198 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
- package/hay/apps/web/src/utils/presence.ts +24 -0
- package/hay/apps/web/src/vite-env.d.ts +10 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
- package/hay/apps/web/test/presence.test.ts +18 -0
- package/hay/apps/web/tsconfig.json +8 -0
- package/hay/apps/web/vite.config.ts +13 -0
- package/hay/apps/web/vitest.config.ts +12 -0
- package/hay/package-lock.json +5988 -0
- package/hay/package.json +18 -0
- package/hay/packages/shared/package.json +27 -0
- package/hay/packages/shared/src/colors.ts +16 -0
- package/hay/packages/shared/src/index.ts +2 -0
- package/hay/packages/shared/src/protocol.ts +90 -0
- package/hay/packages/shared/test/protocol.test.ts +33 -0
- package/hay/packages/shared/tsconfig.json +7 -0
- package/hay/tsconfig.base.json +15 -0
- package/hay-web/assets/index-CTTzApRp.css +32 -0
- package/hay-web/assets/index-FDfdolCs.js +60 -0
- package/hay-web/assets/index-FDfdolCs.js.map +1 -0
- package/hay-web/index.html +13 -0
- package/hop +3717 -0
- package/package.json +52 -0
- package/sessions.html +243 -0
- package/tunnel-shell.js +254 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export type OptimisticEchoOptions = {
|
|
2
|
+
maxPendingMs?: number;
|
|
3
|
+
now?: () => number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type OptimisticEcho = {
|
|
7
|
+
onInput: (data: string, enabled: boolean) => string;
|
|
8
|
+
reconcileOutput: (data: string) => string;
|
|
9
|
+
reset: () => void;
|
|
10
|
+
getPending: () => string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_PENDING_MS = 800;
|
|
14
|
+
|
|
15
|
+
const isPrintable = (char: string) => {
|
|
16
|
+
const code = char.charCodeAt(0);
|
|
17
|
+
return code >= 0x20 && code <= 0x7e;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const filterPrintable = (data: string) => {
|
|
21
|
+
let result = "";
|
|
22
|
+
let index = 0;
|
|
23
|
+
while (index < data.length) {
|
|
24
|
+
const char = data[index];
|
|
25
|
+
if (char === "\u001b") {
|
|
26
|
+
index += 1;
|
|
27
|
+
if (data[index] === "[") {
|
|
28
|
+
index += 1;
|
|
29
|
+
while (index < data.length) {
|
|
30
|
+
const code = data.charCodeAt(index);
|
|
31
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
32
|
+
index += 1;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
index += 1;
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (isPrintable(char)) {
|
|
43
|
+
result += char;
|
|
44
|
+
}
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const createOptimisticEcho = (options: OptimisticEchoOptions = {}): OptimisticEcho => {
|
|
51
|
+
const maxPendingMs = options.maxPendingMs ?? DEFAULT_MAX_PENDING_MS;
|
|
52
|
+
const now = options.now ?? (() => Date.now());
|
|
53
|
+
let pending = "";
|
|
54
|
+
let lastEchoAt = 0;
|
|
55
|
+
let mismatchCount = 0;
|
|
56
|
+
|
|
57
|
+
const onInput = (data: string, enabled: boolean) => {
|
|
58
|
+
if (!enabled) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
const filtered = filterPrintable(data);
|
|
62
|
+
if (!filtered) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
pending += filtered;
|
|
66
|
+
lastEchoAt = now();
|
|
67
|
+
return filtered;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const reconcileOutput = (data: string) => {
|
|
71
|
+
if (!pending) {
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (now() - lastEchoAt > maxPendingMs) {
|
|
76
|
+
pending = "";
|
|
77
|
+
mismatchCount = 0;
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let matched = 0;
|
|
82
|
+
const limit = Math.min(pending.length, data.length);
|
|
83
|
+
while (matched < limit && data[matched] === pending[matched]) {
|
|
84
|
+
matched += 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (matched > 0) {
|
|
88
|
+
pending = pending.slice(matched);
|
|
89
|
+
mismatchCount = 0;
|
|
90
|
+
return data.slice(matched);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
mismatchCount += 1;
|
|
94
|
+
if (mismatchCount >= 2) {
|
|
95
|
+
pending = "";
|
|
96
|
+
mismatchCount = 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return data;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const reset = () => {
|
|
103
|
+
pending = "";
|
|
104
|
+
mismatchCount = 0;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const getPending = () => pending;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
onInput,
|
|
111
|
+
reconcileOutput,
|
|
112
|
+
reset,
|
|
113
|
+
getPending
|
|
114
|
+
};
|
|
115
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PresenceClient } from "hay-shared";
|
|
2
|
+
|
|
3
|
+
export const sortPresence = (clients: PresenceClient[], selfId: string | null) => {
|
|
4
|
+
return [...clients].sort((a, b) => {
|
|
5
|
+
if (selfId && a.id === selfId) {
|
|
6
|
+
return -1;
|
|
7
|
+
}
|
|
8
|
+
if (selfId && b.id === selfId) {
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
return a.name.localeCompare(b.name);
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const activityLabel = (client: PresenceClient) => {
|
|
16
|
+
if (client.typing) {
|
|
17
|
+
return "typing";
|
|
18
|
+
}
|
|
19
|
+
const seconds = (Date.now() - client.lastActive) / 1000;
|
|
20
|
+
if (seconds < 15) {
|
|
21
|
+
return "active";
|
|
22
|
+
}
|
|
23
|
+
return "idle";
|
|
24
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createOptimisticEcho } from "../src/utils/optimisticEcho";
|
|
3
|
+
|
|
4
|
+
describe("optimistic echo", () => {
|
|
5
|
+
it("echoes printable input and strips from output", () => {
|
|
6
|
+
const echo = createOptimisticEcho();
|
|
7
|
+
const echoed = echo.onInput("hello", true);
|
|
8
|
+
expect(echoed).toBe("hello");
|
|
9
|
+
const output = echo.reconcileOutput("hello");
|
|
10
|
+
expect(output).toBe("");
|
|
11
|
+
expect(echo.getPending()).toBe("");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("handles partial output chunks", () => {
|
|
15
|
+
const echo = createOptimisticEcho();
|
|
16
|
+
echo.onInput("abc", true);
|
|
17
|
+
const first = echo.reconcileOutput("ab");
|
|
18
|
+
expect(first).toBe("");
|
|
19
|
+
const second = echo.reconcileOutput("cX");
|
|
20
|
+
expect(second).toBe("X");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("filters non-printable input", () => {
|
|
24
|
+
const echo = createOptimisticEcho();
|
|
25
|
+
const echoed = echo.onInput("\u001b[A", true);
|
|
26
|
+
expect(echoed).toBe("");
|
|
27
|
+
expect(echo.getPending()).toBe("");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("clears pending after timeout", () => {
|
|
31
|
+
let time = 0;
|
|
32
|
+
const echo = createOptimisticEcho({ now: () => time, maxPendingMs: 10 });
|
|
33
|
+
echo.onInput("abc", true);
|
|
34
|
+
time = 20;
|
|
35
|
+
const output = echo.reconcileOutput("XYZ");
|
|
36
|
+
expect(output).toBe("XYZ");
|
|
37
|
+
expect(echo.getPending()).toBe("");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sortPresence } from "../src/utils/presence";
|
|
3
|
+
|
|
4
|
+
const makeClient = (id: string, name: string) => ({
|
|
5
|
+
id,
|
|
6
|
+
name,
|
|
7
|
+
color: "#fff",
|
|
8
|
+
typing: false,
|
|
9
|
+
lastActive: Date.now()
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("sortPresence", () => {
|
|
13
|
+
it("keeps self at the top", () => {
|
|
14
|
+
const clients = [makeClient("b", "Blake"), makeClient("a", "Alex")];
|
|
15
|
+
const sorted = sortPresence(clients, "a");
|
|
16
|
+
expect(sorted[0].id).toBe("a");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [react()],
|
|
6
|
+
test: {
|
|
7
|
+
environment: "jsdom",
|
|
8
|
+
setupFiles: "./src/setupTests.ts",
|
|
9
|
+
globals: true,
|
|
10
|
+
exclude: ["playwright/**"]
|
|
11
|
+
}
|
|
12
|
+
});
|