frontpage-host 0.1.7 → 0.1.9
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/AGENTS.md +10 -6
- package/dist/commands/deploy.js +9 -27
- package/dist/commands/media.js +24 -14
- package/dist/commands/prompt-attach.d.ts +12 -10
- package/dist/commands/prompt-attach.js +55 -22
- package/dist/commands/prompt.js +3 -1
- package/dist/commands/push.js +55 -19
- package/dist/commands/runs.js +33 -19
- package/dist/commands/wait-agent-idle.d.ts +49 -0
- package/dist/commands/wait-agent-idle.js +181 -0
- package/dist/http/client.d.ts +13 -7
- package/dist/http/client.js +46 -51
- package/dist/media/limits.d.ts +18 -0
- package/dist/media/limits.js +101 -0
- package/dist/media/select-transport.d.ts +27 -0
- package/dist/media/select-transport.js +134 -0
- package/dist/media/upload.d.ts +16 -0
- package/dist/media/upload.js +140 -0
- package/dist/registry.js +41 -16
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.js +32 -12
- package/llms.txt +5 -3
- package/package.json +4 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §CLI Phase D — shared wait for agent-run busy states (queued/running/deploying).
|
|
3
|
+
* Used by `deploy wait` and `push --wait`. Not a Vercel deploy oracle.
|
|
4
|
+
*/
|
|
5
|
+
import { EXIT_OK, EXIT_USAGE, v1Get } from "../http/client.js";
|
|
6
|
+
import { emitFail, emitOk, isJson, progress } from "../runtime.js";
|
|
7
|
+
const BUSY = new Set(["queued", "running", "deploying"]);
|
|
8
|
+
const FAIL_TERMINAL = new Set(["failed", "canceled"]);
|
|
9
|
+
export function isBusyStatus(status) {
|
|
10
|
+
return BUSY.has(status);
|
|
11
|
+
}
|
|
12
|
+
export function isFailTerminal(status) {
|
|
13
|
+
return FAIL_TERMINAL.has(status);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Decide exit after idle. Pure — unit-tested.
|
|
17
|
+
* - If we watched a busy run end: succeeded → 0; failed/canceled → 2.
|
|
18
|
+
* - If never busy: optional fail-closed on latest terminal failure (deploy wait);
|
|
19
|
+
* otherwise idle/succeeded → 0 (push --wait after a file-only push).
|
|
20
|
+
*/
|
|
21
|
+
export function decideIdleExit(args) {
|
|
22
|
+
if (args.observedBusy && args.watched) {
|
|
23
|
+
const status = args.watched.status;
|
|
24
|
+
// Stale snapshot still busy → not success (caller should keep polling).
|
|
25
|
+
if (isBusyStatus(status)) {
|
|
26
|
+
return { exitCode: EXIT_USAGE, run: args.watched, status: "still_busy" };
|
|
27
|
+
}
|
|
28
|
+
if (isFailTerminal(status)) {
|
|
29
|
+
return { exitCode: EXIT_USAGE, run: args.watched, status };
|
|
30
|
+
}
|
|
31
|
+
return { exitCode: EXIT_OK, run: args.watched, status: status || "succeeded" };
|
|
32
|
+
}
|
|
33
|
+
const latest = args.latest;
|
|
34
|
+
if (args.failClosedOnIdleTerminalFailure &&
|
|
35
|
+
latest &&
|
|
36
|
+
isFailTerminal(latest.status)) {
|
|
37
|
+
return { exitCode: EXIT_USAGE, run: latest, status: latest.status };
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
exitCode: EXIT_OK,
|
|
41
|
+
run: latest,
|
|
42
|
+
status: latest?.status ?? "idle",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function waitForAgentIdle(args) {
|
|
46
|
+
const maxMs = args.maxMs ?? 30 * 60 * 1000;
|
|
47
|
+
const pollMs = args.pollMs ?? 2000;
|
|
48
|
+
const started = Date.now();
|
|
49
|
+
let observedBusy = false;
|
|
50
|
+
let watched = null;
|
|
51
|
+
while (Date.now() - started < maxMs) {
|
|
52
|
+
const data = await v1Get(`/api/v1/sites/${encodeURIComponent(args.slug)}/agent-runs`);
|
|
53
|
+
const busy = (data.active ?? []).filter((r) => isBusyStatus(r.status));
|
|
54
|
+
if (busy.length > 0) {
|
|
55
|
+
observedBusy = true;
|
|
56
|
+
// If we were watching a run that left the active set, resolve it before
|
|
57
|
+
// switching — otherwise a failed run can be skipped when the next starts.
|
|
58
|
+
if (watched && !busy.some((r) => r.id === watched.id)) {
|
|
59
|
+
try {
|
|
60
|
+
const detail = await v1Get(`/api/v1/sites/${encodeURIComponent(args.slug)}/agent-runs/${encodeURIComponent(watched.id)}`);
|
|
61
|
+
if (detail.run && isFailTerminal(detail.run.status)) {
|
|
62
|
+
progress("\n");
|
|
63
|
+
return {
|
|
64
|
+
exitCode: EXIT_USAGE,
|
|
65
|
+
run: detail.run,
|
|
66
|
+
observedBusy: true,
|
|
67
|
+
status: detail.run.status,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Fall through and follow the new busy head.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const prevId = watched?.id;
|
|
76
|
+
const still = prevId
|
|
77
|
+
? busy.find((r) => r.id === prevId)
|
|
78
|
+
: undefined;
|
|
79
|
+
const next = still ?? busy.find((r) => r.status === "deploying") ?? busy[0];
|
|
80
|
+
watched = next;
|
|
81
|
+
progress(`\r${next.status} ${next.statusMessage ?? ""} `);
|
|
82
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Idle — if we were watching a busy run, refresh it for terminal fields (error).
|
|
86
|
+
if (observedBusy && watched) {
|
|
87
|
+
const watching = watched;
|
|
88
|
+
let refreshed = watching;
|
|
89
|
+
try {
|
|
90
|
+
const detail = await v1Get(`/api/v1/sites/${encodeURIComponent(args.slug)}/agent-runs/${encodeURIComponent(watching.id)}`);
|
|
91
|
+
if (detail.run)
|
|
92
|
+
refreshed = detail.run;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Fall back to list `run` only when it is the same id.
|
|
96
|
+
if (data.run?.id === watching.id)
|
|
97
|
+
refreshed = data.run;
|
|
98
|
+
}
|
|
99
|
+
watched = refreshed;
|
|
100
|
+
// Active list cleared but our snapshot is still busy → keep polling.
|
|
101
|
+
if (isBusyStatus(refreshed.status)) {
|
|
102
|
+
progress(`\r${refreshed.status} ${refreshed.statusMessage ?? ""} `);
|
|
103
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
progress("\n");
|
|
108
|
+
const decided = decideIdleExit({
|
|
109
|
+
watched,
|
|
110
|
+
latest: data.run,
|
|
111
|
+
observedBusy,
|
|
112
|
+
failClosedOnIdleTerminalFailure: args.failClosedOnIdleTerminalFailure,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
exitCode: decided.exitCode,
|
|
116
|
+
run: decided.run,
|
|
117
|
+
observedBusy,
|
|
118
|
+
status: decided.status,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
progress("\n");
|
|
122
|
+
return {
|
|
123
|
+
exitCode: EXIT_USAGE,
|
|
124
|
+
run: watched,
|
|
125
|
+
observedBusy,
|
|
126
|
+
status: "timed_out",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Emit deploy/push-wait JSON or text from a WaitIdleResult. */
|
|
130
|
+
export function emitWaitOutcome(args) {
|
|
131
|
+
const { result } = args;
|
|
132
|
+
const message = result.status === "timed_out"
|
|
133
|
+
? "Timed out waiting for deploy."
|
|
134
|
+
: result.status === "still_busy"
|
|
135
|
+
? "Timed out waiting for deploy (run still busy)."
|
|
136
|
+
: result.run?.error ||
|
|
137
|
+
result.run?.statusMessage ||
|
|
138
|
+
(isFailTerminal(result.status)
|
|
139
|
+
? `Latest run ${result.status}`
|
|
140
|
+
: result.status);
|
|
141
|
+
if (result.exitCode !== EXIT_OK) {
|
|
142
|
+
if (isJson()) {
|
|
143
|
+
return emitFail(args.command, {
|
|
144
|
+
message,
|
|
145
|
+
exitCode: result.exitCode,
|
|
146
|
+
data: {
|
|
147
|
+
slug: args.slug,
|
|
148
|
+
waited: true,
|
|
149
|
+
observedBusy: result.observedBusy,
|
|
150
|
+
status: result.status,
|
|
151
|
+
run: result.run,
|
|
152
|
+
...args.extra,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (result.run) {
|
|
157
|
+
console.error(`${result.run.id} ${result.run.status}${result.run.error ? ` ${result.run.error}` : result.run.statusMessage ? ` ${result.run.statusMessage}` : ""}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.error(message);
|
|
161
|
+
}
|
|
162
|
+
return result.exitCode;
|
|
163
|
+
}
|
|
164
|
+
if (isJson()) {
|
|
165
|
+
emitOk(args.command, {
|
|
166
|
+
slug: args.slug,
|
|
167
|
+
waited: true,
|
|
168
|
+
observedBusy: result.observedBusy,
|
|
169
|
+
status: result.status,
|
|
170
|
+
run: result.run,
|
|
171
|
+
...args.extra,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
else if (result.run) {
|
|
175
|
+
console.log(`${result.run.id} ${result.run.status}${result.run.statusMessage ? ` ${result.run.statusMessage}` : ""}`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
console.log("No active deploy.");
|
|
179
|
+
}
|
|
180
|
+
return EXIT_OK;
|
|
181
|
+
}
|
package/dist/http/client.d.ts
CHANGED
|
@@ -3,13 +3,6 @@ export declare const EXIT_USAGE = 2;
|
|
|
3
3
|
export declare const EXIT_CONFLICT = 3;
|
|
4
4
|
export declare const EXIT_AUTH = 4;
|
|
5
5
|
export declare const EXIT_UNEXPECTED = 5;
|
|
6
|
-
export declare class CliHttpError extends Error {
|
|
7
|
-
readonly exitCode: number;
|
|
8
|
-
readonly status: number;
|
|
9
|
-
readonly code?: string;
|
|
10
|
-
constructor(message: string, exitCode: number, status: number, code?: string);
|
|
11
|
-
}
|
|
12
|
-
export declare function apiBase(): string;
|
|
13
6
|
export type V1Envelope = {
|
|
14
7
|
error?: {
|
|
15
8
|
code?: string;
|
|
@@ -17,6 +10,19 @@ export type V1Envelope = {
|
|
|
17
10
|
requestId?: string;
|
|
18
11
|
};
|
|
19
12
|
};
|
|
13
|
+
export declare class CliHttpError extends Error {
|
|
14
|
+
readonly exitCode: number;
|
|
15
|
+
readonly status: number;
|
|
16
|
+
readonly code?: string;
|
|
17
|
+
/** v1 envelope requestId when the API returned one (§CLI Phase D). */
|
|
18
|
+
readonly requestId?: string;
|
|
19
|
+
constructor(message: string, exitCode: number, status: number, code?: string, requestId?: string);
|
|
20
|
+
}
|
|
21
|
+
/** Map a non-OK v1 response into CliHttpError (shared by JSON + multipart clients). */
|
|
22
|
+
export declare function httpErrorFromV1(status: number, parsed: V1Envelope, fallbackMessage?: string): CliHttpError;
|
|
23
|
+
export declare function apiBase(): string;
|
|
24
|
+
/** Bearer header for raw fetch (e.g. Blob client-token exchange). */
|
|
25
|
+
export declare function getBearerAuthHeader(): Promise<Record<string, string>>;
|
|
20
26
|
export declare function v1Get<T>(path: string): Promise<T>;
|
|
21
27
|
export declare function v1Post<T>(path: string, body?: unknown): Promise<T>;
|
|
22
28
|
export declare function v1Put<T>(path: string, body?: unknown): Promise<T>;
|
package/dist/http/client.js
CHANGED
|
@@ -12,20 +12,63 @@ export class CliHttpError extends Error {
|
|
|
12
12
|
exitCode;
|
|
13
13
|
status;
|
|
14
14
|
code;
|
|
15
|
-
|
|
15
|
+
/** v1 envelope requestId when the API returned one (§CLI Phase D). */
|
|
16
|
+
requestId;
|
|
17
|
+
constructor(message, exitCode, status, code, requestId) {
|
|
16
18
|
super(message);
|
|
17
19
|
this.name = "CliHttpError";
|
|
18
20
|
this.exitCode = exitCode;
|
|
19
21
|
this.status = status;
|
|
20
22
|
this.code = code;
|
|
23
|
+
this.requestId = requestId;
|
|
21
24
|
}
|
|
22
25
|
}
|
|
26
|
+
/** Map a non-OK v1 response into CliHttpError (shared by JSON + multipart clients). */
|
|
27
|
+
export function httpErrorFromV1(status, parsed, fallbackMessage) {
|
|
28
|
+
const message = parsed.error?.message ?? fallbackMessage ?? `Request failed (${status})`;
|
|
29
|
+
const code = parsed.error?.code;
|
|
30
|
+
const requestId = parsed.error?.requestId;
|
|
31
|
+
if (status === 401 || status === 403) {
|
|
32
|
+
return new CliHttpError(message, EXIT_AUTH, status, code, requestId);
|
|
33
|
+
}
|
|
34
|
+
if (status === 400 || code === "bad_request") {
|
|
35
|
+
return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
|
|
36
|
+
}
|
|
37
|
+
if (status === 402 || status === 429 || code === "rate_limited" || code === "payment_required") {
|
|
38
|
+
return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
|
|
39
|
+
}
|
|
40
|
+
if (status === 409 || code === "conflict") {
|
|
41
|
+
return new CliHttpError(message, EXIT_CONFLICT, status, code, requestId);
|
|
42
|
+
}
|
|
43
|
+
if (status === 413 || code === "payload_too_large") {
|
|
44
|
+
return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
|
|
45
|
+
}
|
|
46
|
+
if (status === 503 || code === "unavailable") {
|
|
47
|
+
return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
|
|
48
|
+
}
|
|
49
|
+
if (status === 404) {
|
|
50
|
+
const lower = message.toLowerCase();
|
|
51
|
+
if (lower === "not found." || lower === "not found") {
|
|
52
|
+
return new CliHttpError("API not available (is PUBLIC_CLI_ENABLED on for this environment?).", EXIT_UNEXPECTED, status, code, requestId);
|
|
53
|
+
}
|
|
54
|
+
return new CliHttpError(message, EXIT_AUTH, status, code, requestId);
|
|
55
|
+
}
|
|
56
|
+
return new CliHttpError(message, EXIT_UNEXPECTED, status, code, requestId);
|
|
57
|
+
}
|
|
23
58
|
export function apiBase() {
|
|
24
59
|
const fromEnv = process.env.FRONTPAGE_API_BASE?.replace(/\/$/, "");
|
|
25
60
|
if (fromEnv)
|
|
26
61
|
return fromEnv;
|
|
27
62
|
return "https://app.frontpage.host";
|
|
28
63
|
}
|
|
64
|
+
/** Bearer header for raw fetch (e.g. Blob client-token exchange). */
|
|
65
|
+
export async function getBearerAuthHeader() {
|
|
66
|
+
const key = await getApiKey();
|
|
67
|
+
if (!key) {
|
|
68
|
+
throw new CliHttpError("Not logged in. Run: frontpage login", EXIT_AUTH, 401);
|
|
69
|
+
}
|
|
70
|
+
return { Authorization: `Bearer ${key}` };
|
|
71
|
+
}
|
|
29
72
|
async function v1Request(method, path, body) {
|
|
30
73
|
const key = await getApiKey();
|
|
31
74
|
if (!key) {
|
|
@@ -58,31 +101,7 @@ async function v1Request(method, path, body) {
|
|
|
58
101
|
}
|
|
59
102
|
if (res.ok)
|
|
60
103
|
return parsed;
|
|
61
|
-
|
|
62
|
-
const code = parsed.error?.code;
|
|
63
|
-
if (res.status === 401 || res.status === 403) {
|
|
64
|
-
throw new CliHttpError(message, EXIT_AUTH, res.status, code);
|
|
65
|
-
}
|
|
66
|
-
if (res.status === 402 || res.status === 429 || code === "rate_limited" || code === "payment_required") {
|
|
67
|
-
throw new CliHttpError(message, EXIT_USAGE, res.status, code);
|
|
68
|
-
}
|
|
69
|
-
if (res.status === 409 || code === "conflict") {
|
|
70
|
-
throw new CliHttpError(message, EXIT_CONFLICT, res.status, code);
|
|
71
|
-
}
|
|
72
|
-
if (res.status === 413 || code === "payload_too_large") {
|
|
73
|
-
throw new CliHttpError(message, EXIT_USAGE, res.status, code);
|
|
74
|
-
}
|
|
75
|
-
if (res.status === 503 || code === "unavailable") {
|
|
76
|
-
throw new CliHttpError(message, EXIT_USAGE, res.status, code);
|
|
77
|
-
}
|
|
78
|
-
if (res.status === 404) {
|
|
79
|
-
const lower = message.toLowerCase();
|
|
80
|
-
if (lower === "not found." || lower === "not found") {
|
|
81
|
-
throw new CliHttpError("API not available (is PUBLIC_CLI_ENABLED on for this environment?).", EXIT_UNEXPECTED, res.status, code);
|
|
82
|
-
}
|
|
83
|
-
throw new CliHttpError(message, EXIT_AUTH, res.status, code);
|
|
84
|
-
}
|
|
85
|
-
throw new CliHttpError(message, EXIT_UNEXPECTED, res.status, code);
|
|
104
|
+
throw httpErrorFromV1(res.status, parsed);
|
|
86
105
|
}
|
|
87
106
|
export async function v1Get(path) {
|
|
88
107
|
return v1Request("GET", path);
|
|
@@ -130,31 +149,7 @@ export async function v1PostMultipart(path, form) {
|
|
|
130
149
|
}
|
|
131
150
|
if (res.ok)
|
|
132
151
|
return parsed;
|
|
133
|
-
|
|
134
|
-
const code = parsed.error?.code;
|
|
135
|
-
if (res.status === 401 || res.status === 403) {
|
|
136
|
-
throw new CliHttpError(message, EXIT_AUTH, res.status, code);
|
|
137
|
-
}
|
|
138
|
-
if (res.status === 402 || res.status === 429 || code === "rate_limited" || code === "payment_required") {
|
|
139
|
-
throw new CliHttpError(message, EXIT_USAGE, res.status, code);
|
|
140
|
-
}
|
|
141
|
-
if (res.status === 409 || code === "conflict") {
|
|
142
|
-
throw new CliHttpError(message, EXIT_CONFLICT, res.status, code);
|
|
143
|
-
}
|
|
144
|
-
if (res.status === 413 || code === "payload_too_large") {
|
|
145
|
-
throw new CliHttpError(message, EXIT_USAGE, res.status, code);
|
|
146
|
-
}
|
|
147
|
-
if (res.status === 503 || code === "unavailable") {
|
|
148
|
-
throw new CliHttpError(message, EXIT_USAGE, res.status, code);
|
|
149
|
-
}
|
|
150
|
-
if (res.status === 404) {
|
|
151
|
-
const lower = message.toLowerCase();
|
|
152
|
-
if (lower === "not found." || lower === "not found") {
|
|
153
|
-
throw new CliHttpError("API not available (is PUBLIC_CLI_ENABLED on for this environment?).", EXIT_UNEXPECTED, res.status, code);
|
|
154
|
-
}
|
|
155
|
-
throw new CliHttpError(message, EXIT_AUTH, res.status, code);
|
|
156
|
-
}
|
|
157
|
-
throw new CliHttpError(message, EXIT_UNEXPECTED, res.status, code);
|
|
152
|
+
throw httpErrorFromV1(res.status, parsed);
|
|
158
153
|
}
|
|
159
154
|
export function exitCodeForError(err) {
|
|
160
155
|
if (err instanceof CliHttpError)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror of app `src/lib/upload/limits.ts` + video ext set (CLI cannot import app code).
|
|
3
|
+
* Keep values in lockstep with the platform.
|
|
4
|
+
*/
|
|
5
|
+
export declare const MAX_UPLOAD_BYTES: number;
|
|
6
|
+
export declare const MAX_VIDEO_UPLOAD_BYTES: number;
|
|
7
|
+
export declare const MAX_PROXY_UPLOAD_BYTES: number;
|
|
8
|
+
export declare const MAX_INLINE_TEXT_BYTES: number;
|
|
9
|
+
export declare const VIDEO_UPLOAD_EXT: Set<string>;
|
|
10
|
+
export declare const RASTER_EXT: Set<string>;
|
|
11
|
+
export declare const PDF_EXT: Set<string>;
|
|
12
|
+
export declare const SVG_EXT: Set<string>;
|
|
13
|
+
/** Common text/code extensions (proxy/inline only via CLI — no FU.9 client path). */
|
|
14
|
+
export declare const TEXT_EXT: Set<string>;
|
|
15
|
+
export declare function formatBytes(n: number): string;
|
|
16
|
+
export declare function extOf(filename: string): string;
|
|
17
|
+
export declare function contentTypeForExt(ext: string): string | undefined;
|
|
18
|
+
export declare function sitePathFromKey(key: string): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror of app `src/lib/upload/limits.ts` + video ext set (CLI cannot import app code).
|
|
3
|
+
* Keep values in lockstep with the platform.
|
|
4
|
+
*/
|
|
5
|
+
export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
6
|
+
export const MAX_VIDEO_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
export const MAX_PROXY_UPLOAD_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
export const MAX_INLINE_TEXT_BYTES = 256 * 1024;
|
|
9
|
+
export const VIDEO_UPLOAD_EXT = new Set(["mp4", "webm", "mov", "m4v"]);
|
|
10
|
+
export const RASTER_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif"]);
|
|
11
|
+
export const PDF_EXT = new Set(["pdf"]);
|
|
12
|
+
export const SVG_EXT = new Set(["svg"]);
|
|
13
|
+
/** Common text/code extensions (proxy/inline only via CLI — no FU.9 client path). */
|
|
14
|
+
export const TEXT_EXT = new Set([
|
|
15
|
+
"txt",
|
|
16
|
+
"md",
|
|
17
|
+
"markdown",
|
|
18
|
+
"csv",
|
|
19
|
+
"tsv",
|
|
20
|
+
"json",
|
|
21
|
+
"json5",
|
|
22
|
+
"jsonc",
|
|
23
|
+
"html",
|
|
24
|
+
"htm",
|
|
25
|
+
"css",
|
|
26
|
+
"scss",
|
|
27
|
+
"sass",
|
|
28
|
+
"less",
|
|
29
|
+
"js",
|
|
30
|
+
"mjs",
|
|
31
|
+
"cjs",
|
|
32
|
+
"jsx",
|
|
33
|
+
"ts",
|
|
34
|
+
"tsx",
|
|
35
|
+
"py",
|
|
36
|
+
"rb",
|
|
37
|
+
"php",
|
|
38
|
+
"go",
|
|
39
|
+
"rs",
|
|
40
|
+
"java",
|
|
41
|
+
"kt",
|
|
42
|
+
"swift",
|
|
43
|
+
"c",
|
|
44
|
+
"h",
|
|
45
|
+
"cpp",
|
|
46
|
+
"cc",
|
|
47
|
+
"yaml",
|
|
48
|
+
"yml",
|
|
49
|
+
"toml",
|
|
50
|
+
"ini",
|
|
51
|
+
"env",
|
|
52
|
+
"xml",
|
|
53
|
+
"sql",
|
|
54
|
+
"sh",
|
|
55
|
+
"graphql",
|
|
56
|
+
"gql",
|
|
57
|
+
]);
|
|
58
|
+
export function formatBytes(n) {
|
|
59
|
+
if (!Number.isFinite(n) || n < 0)
|
|
60
|
+
return "0 bytes";
|
|
61
|
+
if (n < 1024)
|
|
62
|
+
return `${Math.round(n)} bytes`;
|
|
63
|
+
if (n < 1024 * 1024)
|
|
64
|
+
return `${(n / 1024).toFixed(0)} KB`;
|
|
65
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
66
|
+
}
|
|
67
|
+
export function extOf(filename) {
|
|
68
|
+
const base = (filename || "").split(/[/\\]/).pop() ?? "";
|
|
69
|
+
return base.includes(".") ? (base.split(".").pop() ?? "").toLowerCase() : "";
|
|
70
|
+
}
|
|
71
|
+
export function contentTypeForExt(ext) {
|
|
72
|
+
switch (ext) {
|
|
73
|
+
case "png":
|
|
74
|
+
return "image/png";
|
|
75
|
+
case "jpg":
|
|
76
|
+
case "jpeg":
|
|
77
|
+
return "image/jpeg";
|
|
78
|
+
case "gif":
|
|
79
|
+
return "image/gif";
|
|
80
|
+
case "webp":
|
|
81
|
+
return "image/webp";
|
|
82
|
+
case "avif":
|
|
83
|
+
return "image/avif";
|
|
84
|
+
case "pdf":
|
|
85
|
+
return "application/pdf";
|
|
86
|
+
case "mp4":
|
|
87
|
+
return "video/mp4";
|
|
88
|
+
case "webm":
|
|
89
|
+
return "video/webm";
|
|
90
|
+
case "mov":
|
|
91
|
+
return "video/quicktime";
|
|
92
|
+
case "m4v":
|
|
93
|
+
return "video/x-m4v";
|
|
94
|
+
default:
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function sitePathFromKey(key) {
|
|
99
|
+
const k = key.replace(/^\//, "");
|
|
100
|
+
return k.startsWith("__media/") ? `/${k}` : `/__media/${k}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type MediaKind = "image" | "document" | "video" | "text";
|
|
2
|
+
export type TransportDecision = {
|
|
3
|
+
transport: "proxy";
|
|
4
|
+
kind: MediaKind;
|
|
5
|
+
} | {
|
|
6
|
+
transport: "client";
|
|
7
|
+
kind: Exclude<MediaKind, "text">;
|
|
8
|
+
ext: string;
|
|
9
|
+
contentType: string;
|
|
10
|
+
} | {
|
|
11
|
+
transport: "reject";
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Minimal video sniff for files without a recognized extension.
|
|
16
|
+
* Mirrors server `sniffType` enough to avoid classifying AVIF/HEIC as video.
|
|
17
|
+
*/
|
|
18
|
+
export declare function sniffVideoExt(head: Uint8Array): "mp4" | "webm" | null;
|
|
19
|
+
/**
|
|
20
|
+
* Decide proxy vs direct-to-Blob vs reject for a local file.
|
|
21
|
+
* `headBytes` (optional) enables video detection when the extension is missing/wrong.
|
|
22
|
+
*/
|
|
23
|
+
export declare function selectMediaTransport(input: {
|
|
24
|
+
filename: string;
|
|
25
|
+
sizeBytes: number;
|
|
26
|
+
headBytes?: Uint8Array;
|
|
27
|
+
}): TransportDecision;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase E — pure transport matrix for `frontpage media upload`.
|
|
3
|
+
*/
|
|
4
|
+
import { extOf, formatBytes, MAX_PROXY_UPLOAD_BYTES, MAX_UPLOAD_BYTES, MAX_VIDEO_UPLOAD_BYTES, PDF_EXT, RASTER_EXT, SVG_EXT, TEXT_EXT, VIDEO_UPLOAD_EXT, } from "./limits.js";
|
|
5
|
+
function asciiAt(bytes, offset, s) {
|
|
6
|
+
if (bytes.length < offset + s.length)
|
|
7
|
+
return false;
|
|
8
|
+
for (let i = 0; i < s.length; i++)
|
|
9
|
+
if (bytes[offset + i] !== s.charCodeAt(i))
|
|
10
|
+
return false;
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
/** Brands that share ISOBMFF/`ftyp` but are NOT video (must not route to client-as-mp4). */
|
|
14
|
+
const NON_VIDEO_FTYP_BRANDS = new Set(["avif", "avis", "heic", "heif", "mif1", "msf1"]);
|
|
15
|
+
function ftypBrand(head) {
|
|
16
|
+
if (!asciiAt(head, 4, "ftyp") || head.length < 12)
|
|
17
|
+
return null;
|
|
18
|
+
return String.fromCharCode(head[8], head[9], head[10], head[11]).toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Minimal video sniff for files without a recognized extension.
|
|
22
|
+
* Mirrors server `sniffType` enough to avoid classifying AVIF/HEIC as video.
|
|
23
|
+
*/
|
|
24
|
+
export function sniffVideoExt(head) {
|
|
25
|
+
const brand = ftypBrand(head);
|
|
26
|
+
if (brand && NON_VIDEO_FTYP_BRANDS.has(brand))
|
|
27
|
+
return null;
|
|
28
|
+
if (asciiAt(head, 4, "ftyp") ||
|
|
29
|
+
asciiAt(head, 4, "moov") ||
|
|
30
|
+
asciiAt(head, 4, "mdat") ||
|
|
31
|
+
asciiAt(head, 4, "wide") ||
|
|
32
|
+
asciiAt(head, 4, "free") ||
|
|
33
|
+
asciiAt(head, 4, "skip")) {
|
|
34
|
+
return "mp4";
|
|
35
|
+
}
|
|
36
|
+
if (head.length >= 4 && head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) {
|
|
37
|
+
return "webm";
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function clientDecision(kind, ext, contentType) {
|
|
42
|
+
return { transport: "client", kind, ext, contentType };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decide proxy vs direct-to-Blob vs reject for a local file.
|
|
46
|
+
* `headBytes` (optional) enables video detection when the extension is missing/wrong.
|
|
47
|
+
*/
|
|
48
|
+
export function selectMediaTransport(input) {
|
|
49
|
+
const { filename, sizeBytes } = input;
|
|
50
|
+
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) {
|
|
51
|
+
return { transport: "reject", message: "That file is empty — nothing to upload." };
|
|
52
|
+
}
|
|
53
|
+
let ext = extOf(filename);
|
|
54
|
+
const hasKnownNonVideoExt = RASTER_EXT.has(ext) || PDF_EXT.has(ext) || SVG_EXT.has(ext) || TEXT_EXT.has(ext);
|
|
55
|
+
// Only sniff for video when the extension isn't already a known non-video kind
|
|
56
|
+
// (AVIF is ISOBMFF/`ftyp` — sniffing before the raster branch would mis-route it).
|
|
57
|
+
const sniffedVideo = !hasKnownNonVideoExt && input.headBytes ? sniffVideoExt(input.headBytes) : null;
|
|
58
|
+
const isVideo = VIDEO_UPLOAD_EXT.has(ext) || sniffedVideo !== null;
|
|
59
|
+
if (isVideo && !VIDEO_UPLOAD_EXT.has(ext) && sniffedVideo) {
|
|
60
|
+
ext = sniffedVideo;
|
|
61
|
+
}
|
|
62
|
+
if (isVideo) {
|
|
63
|
+
if (sizeBytes > MAX_VIDEO_UPLOAD_BYTES) {
|
|
64
|
+
return {
|
|
65
|
+
transport: "reject",
|
|
66
|
+
message: `This video is ${formatBytes(sizeBytes)}, over the ${MAX_VIDEO_UPLOAD_BYTES / (1024 * 1024)} MB video limit. Please compress it (a web-ready MP4 around 1080p) and try again.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const useExt = VIDEO_UPLOAD_EXT.has(ext) ? ext : sniffedVideo ?? "mp4";
|
|
70
|
+
const contentType = useExt === "webm"
|
|
71
|
+
? "video/webm"
|
|
72
|
+
: useExt === "mov"
|
|
73
|
+
? "video/quicktime"
|
|
74
|
+
: useExt === "m4v"
|
|
75
|
+
? "video/x-m4v"
|
|
76
|
+
: "video/mp4";
|
|
77
|
+
return clientDecision("video", useExt, contentType);
|
|
78
|
+
}
|
|
79
|
+
if (SVG_EXT.has(ext)) {
|
|
80
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
81
|
+
return {
|
|
82
|
+
transport: "reject",
|
|
83
|
+
message: `This SVG is ${formatBytes(sizeBytes)} — SVGs must be under ${MAX_PROXY_UPLOAD_BYTES / (1024 * 1024)} MB.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { transport: "proxy", kind: "image" };
|
|
87
|
+
}
|
|
88
|
+
if (RASTER_EXT.has(ext)) {
|
|
89
|
+
if (sizeBytes > MAX_UPLOAD_BYTES) {
|
|
90
|
+
return {
|
|
91
|
+
transport: "reject",
|
|
92
|
+
message: `This image is ${formatBytes(sizeBytes)}, over the ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB upload limit. Please use a smaller or lower-resolution image.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
96
|
+
const contentType = ext === "jpg" || ext === "jpeg"
|
|
97
|
+
? "image/jpeg"
|
|
98
|
+
: ext === "png"
|
|
99
|
+
? "image/png"
|
|
100
|
+
: ext === "gif"
|
|
101
|
+
? "image/gif"
|
|
102
|
+
: ext === "webp"
|
|
103
|
+
? "image/webp"
|
|
104
|
+
: "image/avif";
|
|
105
|
+
return clientDecision("image", ext === "jpeg" ? "jpg" : ext, contentType);
|
|
106
|
+
}
|
|
107
|
+
return { transport: "proxy", kind: "image" };
|
|
108
|
+
}
|
|
109
|
+
if (PDF_EXT.has(ext)) {
|
|
110
|
+
if (sizeBytes > MAX_UPLOAD_BYTES) {
|
|
111
|
+
return {
|
|
112
|
+
transport: "reject",
|
|
113
|
+
message: `This PDF is ${formatBytes(sizeBytes)}, over the ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB upload limit. Please attach a smaller file.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
117
|
+
return clientDecision("document", "pdf", "application/pdf");
|
|
118
|
+
}
|
|
119
|
+
return { transport: "proxy", kind: "document" };
|
|
120
|
+
}
|
|
121
|
+
if (TEXT_EXT.has(ext)) {
|
|
122
|
+
if (sizeBytes > MAX_PROXY_UPLOAD_BYTES) {
|
|
123
|
+
return {
|
|
124
|
+
transport: "reject",
|
|
125
|
+
message: `This text/data file is ${formatBytes(sizeBytes)}. Large text/data Blob uploads aren't supported via the CLI yet — keep files under ${MAX_PROXY_UPLOAD_BYTES / (1024 * 1024)} MB or use the editor.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { transport: "proxy", kind: "text" };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
transport: "reject",
|
|
132
|
+
message: "That file type isn't supported. Use an image (PNG, JPG, GIF, WebP, AVIF, SVG), a PDF, a video (MP4, WebM, MOV), or a text/code file.",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type UploadedMedia = {
|
|
2
|
+
kind: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
key?: string;
|
|
5
|
+
sitePath?: string;
|
|
6
|
+
filename?: string;
|
|
7
|
+
sizeBytes?: number;
|
|
8
|
+
contentType?: string;
|
|
9
|
+
content?: string;
|
|
10
|
+
transport?: "proxy" | "client";
|
|
11
|
+
};
|
|
12
|
+
/** Upload a local file using the Phase E transport matrix. */
|
|
13
|
+
export declare function uploadLocalMedia(args: {
|
|
14
|
+
slug: string;
|
|
15
|
+
filePath: string;
|
|
16
|
+
}): Promise<UploadedMedia>;
|