peekable 0.5.0 → 0.6.1
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/api.d.ts +2 -0
- package/dist/api.js +25 -1
- package/dist/cliUpdateNotice.d.ts +8 -0
- package/dist/cliUpdateNotice.js +34 -0
- package/dist/commands/create.js +7 -1
- package/dist/commands/feedback.js +54 -1
- package/dist/commands/proxy.d.ts +1 -0
- package/dist/commands/proxy.js +91 -44
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +22 -0
package/dist/api.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ShareConfig } from "./config.js";
|
|
2
2
|
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
3
3
|
export declare const DEFAULT_UPLOAD_TIMEOUT_MS = 300000;
|
|
4
|
+
export declare function sanitizeTerminalText(value: string): string;
|
|
4
5
|
export declare const USER_AGENT: string;
|
|
5
6
|
export declare class ApiError extends Error {
|
|
6
7
|
status: number;
|
|
@@ -10,6 +11,7 @@ export declare class ApiError extends Error {
|
|
|
10
11
|
export declare function api(config: ShareConfig, method: string, path: string, body?: unknown, opts?: {
|
|
11
12
|
timeoutMs?: number;
|
|
12
13
|
}): Promise<any>;
|
|
14
|
+
export declare function apiBinary(config: ShareConfig, path: string): Promise<Buffer>;
|
|
13
15
|
export declare function uploadVideo(config: ShareConfig, sessionId: string, opts: {
|
|
14
16
|
video: Blob;
|
|
15
17
|
filename: string;
|
package/dist/api.js
CHANGED
|
@@ -2,7 +2,7 @@ import { CLI_VERSION } from "./version.js";
|
|
|
2
2
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
3
3
|
export const DEFAULT_UPLOAD_TIMEOUT_MS = 300_000;
|
|
4
4
|
const CONTROL_CHARS = new RegExp("[\\x00-\\x1f\\x7f-\\x9f]", "g");
|
|
5
|
-
function sanitizeTerminalText(value) {
|
|
5
|
+
export function sanitizeTerminalText(value) {
|
|
6
6
|
return value.replace(CONTROL_CHARS, "").slice(0, 500);
|
|
7
7
|
}
|
|
8
8
|
export const USER_AGENT = `peekable-cli/${CLI_VERSION} (${process.platform}; ${process.arch}; node ${process.version})`;
|
|
@@ -31,6 +31,30 @@ export async function api(config, method, path, body, opts = {}) {
|
|
|
31
31
|
}
|
|
32
32
|
return res.json();
|
|
33
33
|
}
|
|
34
|
+
export async function apiBinary(config, path) {
|
|
35
|
+
const apiPath = path.startsWith("/api/") ? path.slice(4) : path;
|
|
36
|
+
const res = await fetchWithTimeout(`${config.url}/api${apiPath}`, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
40
|
+
"User-Agent": USER_AGENT,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
throw await buildApiError(res);
|
|
45
|
+
}
|
|
46
|
+
// Stored frames are server-capped at 120KB; anything past 1MB is a
|
|
47
|
+
// misbehaving server and must not be buffered into memory unbounded.
|
|
48
|
+
const declaredLength = Number(res.headers.get("content-length"));
|
|
49
|
+
if (Number.isFinite(declaredLength) && declaredLength > 1_000_000) {
|
|
50
|
+
throw new Error("frame exceeds 1MB");
|
|
51
|
+
}
|
|
52
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
53
|
+
if (buffer.byteLength > 1_000_000) {
|
|
54
|
+
throw new Error("frame exceeds 1MB");
|
|
55
|
+
}
|
|
56
|
+
return buffer;
|
|
57
|
+
}
|
|
34
58
|
export async function uploadVideo(config, sessionId, opts) {
|
|
35
59
|
const form = new FormData();
|
|
36
60
|
form.set("video", opts.video, opts.filename);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type CliUpdateNotice = {
|
|
2
|
+
current: string;
|
|
3
|
+
latest: string;
|
|
4
|
+
min: string;
|
|
5
|
+
action: "recommended" | "required";
|
|
6
|
+
};
|
|
7
|
+
export declare function readCliUpdate(data: unknown): CliUpdateNotice | null;
|
|
8
|
+
export declare function warnCliUpdateToStderr(notice: CliUpdateNotice): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Surfaces the server's "your CLI is stale" notice that rides on the
|
|
2
|
+
// session-create response (see src/cliVersion.ts server-side). A published CLI
|
|
3
|
+
// can silently lag the server's capabilities; this makes that visible to both
|
|
4
|
+
// humans (stderr) and agents (the --json payload) at the moment a session
|
|
5
|
+
// starts. The fix is always `npm i -g peekable@latest` — never `peekable
|
|
6
|
+
// upgrade`, which is the billing command.
|
|
7
|
+
// Extract and validate the notice from a session-create response body.
|
|
8
|
+
// Returns null when absent or malformed — a missing notice must never break
|
|
9
|
+
// the command.
|
|
10
|
+
export function readCliUpdate(data) {
|
|
11
|
+
if (!data || typeof data !== "object")
|
|
12
|
+
return null;
|
|
13
|
+
const u = data.cli_update;
|
|
14
|
+
if (!u || typeof u !== "object")
|
|
15
|
+
return null;
|
|
16
|
+
const notice = u;
|
|
17
|
+
if (typeof notice.current !== "string" || typeof notice.latest !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
return {
|
|
20
|
+
current: notice.current,
|
|
21
|
+
latest: notice.latest,
|
|
22
|
+
min: typeof notice.min === "string" ? notice.min : notice.latest,
|
|
23
|
+
action: notice.action === "required" ? "required" : "recommended",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// Human-facing warning printed to stderr (so it never pollutes --json stdout).
|
|
27
|
+
// Non-blocking: the command still runs on a stale CLI.
|
|
28
|
+
export function warnCliUpdateToStderr(notice) {
|
|
29
|
+
const lead = notice.action === "required"
|
|
30
|
+
? `⚠ Your Peekable CLI (v${notice.current}) is out of date — some features (e.g. tunnelling apps that call their own backend) may not work until you update.`
|
|
31
|
+
: `↑ A newer Peekable CLI is available (v${notice.current} → v${notice.latest}).`;
|
|
32
|
+
console.error(lead);
|
|
33
|
+
console.error(" Update: npm i -g peekable@latest");
|
|
34
|
+
}
|
package/dist/commands/create.js
CHANGED
|
@@ -3,21 +3,27 @@ import { requireConfig } from "../config.js";
|
|
|
3
3
|
import { api } from "../api.js";
|
|
4
4
|
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
5
5
|
import { runWithTelemetry } from "../telemetry.js";
|
|
6
|
+
import { readCliUpdate, warnCliUpdateToStderr } from "../cliUpdateNotice.js";
|
|
6
7
|
export const createCommand = new Command("create")
|
|
7
8
|
.argument("<name>", "Session name")
|
|
8
9
|
.description("Create a new sharing session")
|
|
9
10
|
.option("--json", "Output JSON")
|
|
11
|
+
.option("--no-slug", "Use an opaque hostname that omits the session name (for sensitive shares)")
|
|
10
12
|
.action(async (name, opts) => {
|
|
11
13
|
await runWithTelemetry("create", async () => {
|
|
12
14
|
try {
|
|
13
15
|
const config = requireConfig();
|
|
14
|
-
const data = await api(config, "POST", "/sessions", { name });
|
|
16
|
+
const data = await api(config, "POST", "/sessions", { name, slug: opts.slug });
|
|
17
|
+
const cliUpdate = readCliUpdate(data);
|
|
15
18
|
if (opts.json) {
|
|
19
|
+
// data already carries cli_update when present; agents read it there.
|
|
16
20
|
console.log(JSON.stringify(data));
|
|
17
21
|
}
|
|
18
22
|
else {
|
|
19
23
|
console.log(`Created session: ${data.id}`);
|
|
20
24
|
console.log(`URL: ${data.url}`);
|
|
25
|
+
if (cliUpdate)
|
|
26
|
+
warnCliUpdateToStderr(cliUpdate);
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
catch (err) {
|
|
@@ -1,21 +1,65 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { requireConfig } from "../config.js";
|
|
3
|
-
import { api } from "../api.js";
|
|
3
|
+
import { api, apiBinary, sanitizeTerminalText } from "../api.js";
|
|
4
4
|
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
5
5
|
import { runWithTelemetry } from "../telemetry.js";
|
|
6
|
+
function formatTimecode(atS) {
|
|
7
|
+
const seconds = Number.isFinite(atS) ? Math.max(0, atS) : 0;
|
|
8
|
+
return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
|
|
9
|
+
}
|
|
10
|
+
function isVideoAnnotation(annotation) {
|
|
11
|
+
return typeof annotation.comment === "string" && typeof annotation.at_s === "number";
|
|
12
|
+
}
|
|
13
|
+
const FRAME_URL_PATTERN = /^\/api\/sessions\/[A-Za-z0-9_-]+\/annotations\/[A-Za-z0-9_-]+\/frame$/;
|
|
14
|
+
async function includeFrames(config, annotationsData) {
|
|
15
|
+
const items = Array.isArray(annotationsData.annotations)
|
|
16
|
+
? annotationsData.annotations.flatMap((group) => Array.isArray(group.items) ? group.items : [])
|
|
17
|
+
: [];
|
|
18
|
+
const candidates = items.filter((item) => typeof item.frame_url === "string");
|
|
19
|
+
const frameItems = candidates.filter((item) => {
|
|
20
|
+
// Only canonical annotation-frame paths are fetched with the bearer key —
|
|
21
|
+
// a server response must not be able to steer the CLI at other endpoints.
|
|
22
|
+
if (FRAME_URL_PATTERN.test(item.frame_url))
|
|
23
|
+
return true;
|
|
24
|
+
item.frame_jpeg = null;
|
|
25
|
+
console.error(`Warning: skipping unexpected frame_url ${sanitizeTerminalText(item.frame_url)}`);
|
|
26
|
+
return false;
|
|
27
|
+
});
|
|
28
|
+
let nextIndex = 0;
|
|
29
|
+
async function worker() {
|
|
30
|
+
while (nextIndex < frameItems.length) {
|
|
31
|
+
const item = frameItems[nextIndex++];
|
|
32
|
+
try {
|
|
33
|
+
const frame = await apiBinary(config, item.frame_url);
|
|
34
|
+
item.frame_jpeg = `data:image/jpeg;base64,${frame.toString("base64")}`;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
item.frame_jpeg = null;
|
|
38
|
+
console.error(`Warning: failed to fetch frame ${sanitizeTerminalText(item.frame_url)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await Promise.all(Array.from({ length: Math.min(4, frameItems.length) }, () => worker()));
|
|
43
|
+
}
|
|
6
44
|
export const feedbackCommand = new Command("feedback")
|
|
7
45
|
.argument("<session-id>", "Session ID")
|
|
8
46
|
.description("Fetch feedback for a session")
|
|
9
47
|
.option("--json", "Output JSON")
|
|
48
|
+
.option("--include-frames", "Inline frame JPEGs as data URIs (requires --json)")
|
|
10
49
|
.action(async (sessionId, opts) => {
|
|
11
50
|
await runWithTelemetry("feedback", async () => {
|
|
12
51
|
try {
|
|
52
|
+
if (opts.includeFrames && !opts.json) {
|
|
53
|
+
throw new Error("--include-frames requires --json");
|
|
54
|
+
}
|
|
13
55
|
const config = requireConfig();
|
|
14
56
|
const [eventsData, annotationsData] = await Promise.all([
|
|
15
57
|
api(config, "GET", `/sessions/${sessionId}/feedback`),
|
|
16
58
|
api(config, "GET", `/sessions/${sessionId}/annotations`).catch(() => ({ annotations: [] })),
|
|
17
59
|
]);
|
|
18
60
|
if (opts.json) {
|
|
61
|
+
if (opts.includeFrames)
|
|
62
|
+
await includeFrames(config, annotationsData);
|
|
19
63
|
console.log(JSON.stringify({ ...eventsData, ...annotationsData }));
|
|
20
64
|
return;
|
|
21
65
|
}
|
|
@@ -42,6 +86,15 @@ export const feedbackCommand = new Command("feedback")
|
|
|
42
86
|
for (const group of annotationsData.annotations) {
|
|
43
87
|
console.log(`\n--- Version ${group.version} ---`);
|
|
44
88
|
for (const ann of group.items) {
|
|
89
|
+
if (isVideoAnnotation(ann)) {
|
|
90
|
+
const layer = sanitizeTerminalText(typeof ann.resolved?.layer === "string" ? ann.resolved.layer : "(unresolved)");
|
|
91
|
+
const status = ann.status === "addressed" && typeof ann.addressed_in === "number"
|
|
92
|
+
? `addressed in v${ann.addressed_in}`
|
|
93
|
+
: sanitizeTerminalText(typeof ann.status === "string" ? ann.status : "");
|
|
94
|
+
console.log(` [${formatTimecode(ann.at_s)}] ${layer} — ${status}`);
|
|
95
|
+
console.log(` "${sanitizeTerminalText(ann.comment)}"`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
45
98
|
const who = ann.viewer ?? "Anonymous";
|
|
46
99
|
const elementName = ann.element_name || "Unknown";
|
|
47
100
|
const selector = ann.selector || "";
|
package/dist/commands/proxy.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
export declare const proxyCommand: Command;
|
|
3
|
+
export declare function handleRequest(ws: WebSocket, msg: any, port: number, inflight: Map<string, AbortController>, fallbackPorts: Set<number>, pathPortCache: Map<string, number>, jsonOutput?: boolean): Promise<void>;
|
package/dist/commands/proxy.js
CHANGED
|
@@ -4,6 +4,7 @@ import { requireConfig } from "../config.js";
|
|
|
4
4
|
import { api } from "../api.js";
|
|
5
5
|
import { CommandFailure, printCommandError } from "../command-error.js";
|
|
6
6
|
import { postStartEvent, postFireAndForgetDebugEvent } from "../telemetry.js";
|
|
7
|
+
import { readCliUpdate, warnCliUpdateToStderr } from "../cliUpdateNotice.js";
|
|
7
8
|
const MAX_RESPONSE_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
8
9
|
const TEXT_CONTENT_TYPES = [
|
|
9
10
|
"text/",
|
|
@@ -34,6 +35,7 @@ export const proxyCommand = new Command("proxy")
|
|
|
34
35
|
// Create or reuse session
|
|
35
36
|
let sessionId;
|
|
36
37
|
let shareUrl;
|
|
38
|
+
let cliUpdate = null;
|
|
37
39
|
if (opts.session) {
|
|
38
40
|
sessionId = opts.session;
|
|
39
41
|
const baseDomain = new URL(config.url).hostname;
|
|
@@ -54,12 +56,16 @@ export const proxyCommand = new Command("proxy")
|
|
|
54
56
|
}
|
|
55
57
|
sessionId = data.id;
|
|
56
58
|
shareUrl = data.url;
|
|
59
|
+
cliUpdate = readCliUpdate(data);
|
|
57
60
|
}
|
|
58
61
|
if (opts.json) {
|
|
59
|
-
|
|
62
|
+
// Agents read cli_update here to prompt the user to update.
|
|
63
|
+
console.log(JSON.stringify({ id: sessionId, url: shareUrl, ...(cliUpdate ? { cli_update: cliUpdate } : {}) }));
|
|
60
64
|
}
|
|
61
65
|
else {
|
|
62
66
|
console.log(`Share URL: ${shareUrl}`);
|
|
67
|
+
if (cliUpdate)
|
|
68
|
+
warnCliUpdateToStderr(cliUpdate);
|
|
63
69
|
}
|
|
64
70
|
postStartEvent("cli_proxy_started", { session_id: sessionId, port });
|
|
65
71
|
// Connect relay WebSocket
|
|
@@ -77,6 +83,7 @@ export const proxyCommand = new Command("proxy")
|
|
|
77
83
|
const wsStartedAt = Date.now();
|
|
78
84
|
const inflight = new Map();
|
|
79
85
|
const fallbackPorts = new Set();
|
|
86
|
+
const pathPortCache = new Map();
|
|
80
87
|
ws.addEventListener("open", () => {
|
|
81
88
|
ws.send(JSON.stringify({
|
|
82
89
|
type: "auth",
|
|
@@ -122,7 +129,7 @@ export const proxyCommand = new Command("proxy")
|
|
|
122
129
|
return;
|
|
123
130
|
}
|
|
124
131
|
if (msg.type === "request") {
|
|
125
|
-
await handleRequest(ws, msg, port, inflight, fallbackPorts, opts.json);
|
|
132
|
+
await handleRequest(ws, msg, port, inflight, fallbackPorts, pathPortCache, opts.json);
|
|
126
133
|
}
|
|
127
134
|
});
|
|
128
135
|
ws.addEventListener("close", (event) => {
|
|
@@ -174,12 +181,18 @@ export const proxyCommand = new Command("proxy")
|
|
|
174
181
|
setTimeout(() => process.exit(0), 200);
|
|
175
182
|
});
|
|
176
183
|
});
|
|
177
|
-
|
|
184
|
+
// Bounds pathPortCache so a long-lived session with many distinct paths can't
|
|
185
|
+
// grow it without limit; oldest entries are evicted first (Map insertion order).
|
|
186
|
+
const MAX_PATH_PORT_CACHE = 512;
|
|
187
|
+
export async function handleRequest(ws, msg, port, inflight, fallbackPorts, pathPortCache, jsonOutput) {
|
|
178
188
|
const ac = new AbortController();
|
|
179
189
|
inflight.set(msg.id, ac);
|
|
180
190
|
try {
|
|
181
|
-
|
|
182
|
-
//
|
|
191
|
+
// Build headers, skip host and x-forwarded-*. Origin is always stamped as
|
|
192
|
+
// the PRIMARY port: a real browser tab on localhost:<primary> calling any
|
|
193
|
+
// backend port sends Origin http://localhost:<primary>. APIs with origin
|
|
194
|
+
// allowlists (better-auth trustedOrigins, CORS) reject a fabricated
|
|
195
|
+
// per-backend origin — that 403 broke login through tunnels.
|
|
183
196
|
const headers = new Headers();
|
|
184
197
|
if (Array.isArray(msg.headers)) {
|
|
185
198
|
for (const [k, v] of msg.headers) {
|
|
@@ -189,7 +202,6 @@ async function handleRequest(ws, msg, port, inflight, fallbackPorts, jsonOutput)
|
|
|
189
202
|
headers.set(k, v);
|
|
190
203
|
}
|
|
191
204
|
}
|
|
192
|
-
headers.set("host", `localhost:${port}`);
|
|
193
205
|
headers.set("origin", `http://localhost:${port}`);
|
|
194
206
|
headers.set("accept-encoding", "identity");
|
|
195
207
|
// Decode body
|
|
@@ -201,49 +213,84 @@ async function handleRequest(ws, msg, port, inflight, fallbackPorts, jsonOutput)
|
|
|
201
213
|
bytes[i] = binary.charCodeAt(i);
|
|
202
214
|
body = bytes.buffer;
|
|
203
215
|
}
|
|
204
|
-
let upstream = await fetch(url, {
|
|
205
|
-
method: msg.method ?? "GET",
|
|
206
|
-
headers,
|
|
207
|
-
body: body,
|
|
208
|
-
redirect: "manual",
|
|
209
|
-
signal: ac.signal,
|
|
210
|
-
});
|
|
211
|
-
// Detect when primary port can't handle the request — try fallback ports.
|
|
212
|
-
// Key signal: response is HTML but request didn't ask for HTML (fetch vs navigation)
|
|
213
|
-
const upstreamCt = upstream.headers.get("content-type") ?? "";
|
|
214
216
|
const method = (msg.method ?? "GET").toUpperCase();
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
217
|
+
const fetchPort = (p) => {
|
|
218
|
+
const h = new Headers(headers);
|
|
219
|
+
h.set("host", `localhost:${p}`);
|
|
220
|
+
return fetch(`http://localhost:${p}${msg.path}`, {
|
|
221
|
+
method: msg.method ?? "GET",
|
|
222
|
+
headers: h,
|
|
223
|
+
body: body,
|
|
224
|
+
redirect: "manual",
|
|
225
|
+
signal: ac.signal,
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
// Per-path port memoization. Once a path is known to belong to a backend
|
|
229
|
+
// port, go straight there and SKIP the primary hop — this avoids the wasted
|
|
230
|
+
// primary round trip AND, for modifying methods, avoids executing the write
|
|
231
|
+
// twice (primary + fallback). Key excludes the query string so it stays
|
|
232
|
+
// small; the port that owns a path does not vary by query.
|
|
233
|
+
const cacheKey = `${method} ${(msg.path ?? "").split("?")[0]}`;
|
|
234
|
+
const preferred = pathPortCache.get(cacheKey);
|
|
235
|
+
let upstream;
|
|
236
|
+
let servedPort = port;
|
|
237
|
+
if (preferred !== undefined && preferred !== port) {
|
|
238
|
+
try {
|
|
239
|
+
const r = await fetchPort(preferred);
|
|
240
|
+
if (r.status !== 404) {
|
|
241
|
+
upstream = r;
|
|
242
|
+
servedPort = preferred;
|
|
241
243
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Preferred port unreachable (backend restarted / moved) — fall through
|
|
247
|
+
// to the primary + rediscovery path and let the cache re-learn.
|
|
248
|
+
pathPortCache.delete(cacheKey);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (upstream === undefined) {
|
|
252
|
+
upstream = await fetchPort(port);
|
|
253
|
+
servedPort = port;
|
|
254
|
+
// Detect when the primary port can't handle the request — try fallbacks.
|
|
255
|
+
// Key signal: response is HTML but request didn't ask for HTML (a fetch
|
|
256
|
+
// hitting the SPA catch-all), or any modifying method.
|
|
257
|
+
const upstreamCt = upstream.headers.get("content-type") ?? "";
|
|
258
|
+
const requestAccept = (Array.isArray(msg.headers) ? msg.headers.find(([k]) => k.toLowerCase() === "accept")?.[1] : "") ?? "";
|
|
259
|
+
const requestExpectsHtml = requestAccept.includes("text/html");
|
|
260
|
+
const isModifyingMethod = method !== "GET" && method !== "HEAD";
|
|
261
|
+
const gotUnexpectedHtml = upstreamCt.includes("text/html") && !requestExpectsHtml;
|
|
262
|
+
const shouldTryFallback = (gotUnexpectedHtml || isModifyingMethod) && fallbackPorts.size > 0;
|
|
263
|
+
if (shouldTryFallback) {
|
|
264
|
+
for (const fbPort of fallbackPorts) {
|
|
265
|
+
if (fbPort === port)
|
|
266
|
+
continue;
|
|
267
|
+
try {
|
|
268
|
+
const fbResp = await fetchPort(fbPort);
|
|
269
|
+
if (fbResp.status !== 404) {
|
|
270
|
+
// Fallback port handled the request (any status other than 404).
|
|
271
|
+
upstream = fbResp;
|
|
272
|
+
servedPort = fbPort;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Fallback port not reachable, continue
|
|
278
|
+
}
|
|
244
279
|
}
|
|
245
280
|
}
|
|
246
281
|
}
|
|
282
|
+
// Remember which backend port served this path so the next matching request
|
|
283
|
+
// skips the primary hop. Only cache non-primary wins — a primary-served path
|
|
284
|
+
// already takes the primary-first path with no wasted work.
|
|
285
|
+
if (servedPort !== port && upstream.status !== 404) {
|
|
286
|
+
pathPortCache.delete(cacheKey);
|
|
287
|
+
pathPortCache.set(cacheKey, servedPort);
|
|
288
|
+
if (pathPortCache.size > MAX_PATH_PORT_CACHE) {
|
|
289
|
+
const oldest = pathPortCache.keys().next().value;
|
|
290
|
+
if (oldest !== undefined)
|
|
291
|
+
pathPortCache.delete(oldest);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
247
294
|
// Build response header tuples
|
|
248
295
|
const respHeaders = [];
|
|
249
296
|
upstream.headers.forEach((v, k) => {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.
|
|
1
|
+
export declare const CLI_VERSION = "0.6.1";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.
|
|
1
|
+
export const CLI_VERSION = "0.6.1";
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -24,6 +24,8 @@ When the user wants to share an HTML file (playground, mockup, brainstorming com
|
|
|
24
24
|
|
|
25
25
|
If a session already exists for this topic, reuse it (push creates a new version, collaborators auto-reload).
|
|
26
26
|
|
|
27
|
+
Session names are now visible in the share URL's subdomain (DNS and TLS SNI), not just to whoever opens the link. Avoid putting sensitive information in the session name. Use `peekable create "<name>" --no-slug` to get a nameless, opaque hostname when the name itself is sensitive.
|
|
28
|
+
|
|
27
29
|
### Share a video
|
|
28
30
|
|
|
29
31
|
When the user wants to share a video for review:
|
|
@@ -119,6 +121,19 @@ peekable close <session-id> --json
|
|
|
119
121
|
|
|
120
122
|
Free hosted accounts have an active-session cap. If create/proxy returns a limit error, run `peekable list --json`, close stale sessions, then retry.
|
|
121
123
|
|
|
124
|
+
### Keep the CLI up to date
|
|
125
|
+
|
|
126
|
+
When `peekable create` or `peekable proxy --json` returns a `cli_update` object, the installed CLI is behind the server. Tell the user to update before relying on the session:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
npm i -g peekable@latest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- `"action": "recommended"` — a newer version is available; mention it, then continue.
|
|
133
|
+
- `"action": "required"` — the installed version is missing fixes the current server depends on (e.g. tunnelling an app that calls its own backend). Surface this prominently and recommend updating before trusting the tunnel; the session still runs, but features may misbehave.
|
|
134
|
+
|
|
135
|
+
Use `npm i -g peekable@latest` — never `peekable upgrade`, which is the billing/Stripe command, not a version update.
|
|
136
|
+
|
|
122
137
|
### Diagnose setup
|
|
123
138
|
|
|
124
139
|
When sharing fails, auth looks broken, or a user asks for help debugging:
|
|
@@ -218,6 +233,7 @@ Video items come back in the same `peekable feedback <id> --json` payload as can
|
|
|
218
233
|
},
|
|
219
234
|
"comment": "unreadable at phone size — make it bigger",
|
|
220
235
|
"status": "pending", // pending | resolved | addressed
|
|
236
|
+
"addressed_in": null, // always present; version that addressed it
|
|
221
237
|
"frame_url": "/api/sessions/<id>/annotations/a_01j9x4kq/frame"
|
|
222
238
|
}
|
|
223
239
|
```
|
|
@@ -243,3 +259,9 @@ The rule is an array of server annotation IDs:
|
|
|
243
259
|
> per-layer rather than per-item.
|
|
244
260
|
|
|
245
261
|
Always use server annotation `id` values from `peekable feedback --json` in the array. If those IDs are unknown, use the layer-granularity fallback; it means the layer changed in response to feedback, not that a particular item was addressed.
|
|
262
|
+
|
|
263
|
+
> an item whose id (or layer, in fallback) appears in the new manifest's `changed_by` renders as 'addressed in vN'
|
|
264
|
+
|
|
265
|
+
Video items always include `addressed_in`; it is the addressing version when `status` is `addressed`, otherwise `null`.
|
|
266
|
+
|
|
267
|
+
The non-JSON feedback view renders video comments with timecodes. Frame bytes remain by reference through `frame_url`; use `peekable feedback <id> --json --include-frames` to inline each frame as an item's `frame_jpeg` data URI.
|