@jant/core 0.6.5 → 0.6.7

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { v as url_exports } from "./url-XF0GbKGO.js";
2
- import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-B21j3s4K.js";
2
+ import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-L1UPUArB.js";
3
3
  import { T as time_exports, a as markdown_exports } from "./export-DLukCOO3.js";
4
4
  import "./env-CoSe-1y4.js";
5
5
  import "./github-sync-BtHY2AST.js";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./url-XF0GbKGO.js";
2
- import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-B21j3s4K.js";
2
+ import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-L1UPUArB.js";
3
3
  import { t as createExportService } from "./export-DLukCOO3.js";
4
4
  import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-CoSe-1y4.js";
5
5
  import "./github-sync-BtHY2AST.js";
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
529
529
  async function start(env = process.env, app) {
530
530
  const handler = await createNodeRequestHandler({
531
531
  env,
532
- app: async () => app ?? (await import("./app-ChOEIV6L.js")).createApp()
532
+ app: async () => app ?? (await import("./app-C1QgMNRY.js")).createApp()
533
533
  });
534
534
  const hostname = resolveHost(env);
535
535
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ readErrorMessage,
5
+ readErrorMessageFromText,
6
+ readJsonObject,
7
+ } from "../json.js";
8
+
9
+ function jsonResponse(body: unknown, status = 200): Response {
10
+ return new Response(JSON.stringify(body), {
11
+ status,
12
+ headers: { "Content-Type": "application/json" },
13
+ });
14
+ }
15
+
16
+ function textResponse(body: string, status: number): Response {
17
+ return new Response(body, {
18
+ status,
19
+ headers: { "Content-Type": "text/plain" },
20
+ });
21
+ }
22
+
23
+ describe("readJsonObject", () => {
24
+ it("parses a JSON object body", async () => {
25
+ const res = jsonResponse({ id: "abc", count: 2 });
26
+ expect(await readJsonObject(res)).toEqual({ id: "abc", count: 2 });
27
+ });
28
+
29
+ it("returns empty object for empty body", async () => {
30
+ const res = new Response("", { status: 200 });
31
+ expect(await readJsonObject(res)).toEqual({});
32
+ });
33
+
34
+ it("returns empty object for a JSON primitive", async () => {
35
+ expect(await readJsonObject(jsonResponse("hi"))).toEqual({});
36
+ });
37
+
38
+ it("throws an informative error when body is not JSON", async () => {
39
+ const res = textResponse("Not Found", 404);
40
+ await expect(readJsonObject(res)).rejects.toThrow(
41
+ /Expected JSON \(HTTP 404\) but got: Not Found/,
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("readErrorMessage", () => {
47
+ it("returns the JSON `error` field when present", async () => {
48
+ const res = jsonResponse({ error: "Quota exceeded" }, 400);
49
+ expect(await readErrorMessage(res, "Default")).toBe("Quota exceeded");
50
+ });
51
+
52
+ it("surfaces plain-text body when not JSON (the Not Found case)", async () => {
53
+ const res = textResponse("Not Found", 404);
54
+ expect(await readErrorMessage(res, "Failed to start upload")).toBe(
55
+ "Not Found",
56
+ );
57
+ });
58
+
59
+ it("falls back when body is empty", async () => {
60
+ const res = new Response("", { status: 500 });
61
+ expect(await readErrorMessage(res, "Default")).toBe("Default");
62
+ });
63
+
64
+ it("truncates very long bodies (e.g. error HTML pages)", async () => {
65
+ const long = "x".repeat(500);
66
+ const res = textResponse(long, 502);
67
+ const result = await readErrorMessage(res, "Default");
68
+ expect(result.length).toBeLessThanOrEqual(201);
69
+ expect(result.endsWith("…")).toBe(true);
70
+ });
71
+
72
+ it("falls back when JSON has no `error` field", async () => {
73
+ const res = jsonResponse({ status: "bad" }, 400);
74
+ expect(await readErrorMessage(res, "Default")).toBe('{"status":"bad"}');
75
+ });
76
+ });
77
+
78
+ describe("readErrorMessageFromText", () => {
79
+ it("extracts error from JSON text", () => {
80
+ expect(
81
+ readErrorMessageFromText('{"error":"part too small"}', "Default"),
82
+ ).toBe("part too small");
83
+ });
84
+
85
+ it("returns plain text when not JSON", () => {
86
+ expect(readErrorMessageFromText("Internal Error", "Default")).toBe(
87
+ "Internal Error",
88
+ );
89
+ });
90
+
91
+ it("falls back when text is blank", () => {
92
+ expect(readErrorMessageFromText(" ", "Default")).toBe("Default");
93
+ });
94
+ });
@@ -3319,6 +3319,50 @@ describe("JantComposeDialog", () => {
3319
3319
  expect(el._confirmPanelOpen).toBe(false);
3320
3320
  });
3321
3321
 
3322
+ it("ignores Escape while an IME is composing (e.g. CJK candidate popup)", async () => {
3323
+ // Regression test for GitHub issue #120: when a user types pinyin and
3324
+ // presses Escape to dismiss the IME candidate popup, the compose dialog
3325
+ // must not interpret it as a close request.
3326
+ const el = await createElement();
3327
+ const editor = requireElement(
3328
+ el.querySelector<JantComposeEditor>("jant-compose-editor"),
3329
+ "expected compose editor",
3330
+ );
3331
+ editor._bodyJson = {
3332
+ type: "doc",
3333
+ content: [
3334
+ {
3335
+ type: "paragraph",
3336
+ content: [{ type: "text", text: "已经写了一些内容" }],
3337
+ },
3338
+ ],
3339
+ };
3340
+ await editor.updateComplete;
3341
+
3342
+ const requestCloseSpy = vi.spyOn(el, "requestClose");
3343
+ el.dispatchEvent(
3344
+ new globalThis.KeyboardEvent("keydown", {
3345
+ key: "Escape",
3346
+ isComposing: true,
3347
+ bubbles: true,
3348
+ }),
3349
+ );
3350
+ await el.updateComplete;
3351
+
3352
+ expect(requestCloseSpy).not.toHaveBeenCalled();
3353
+ expect(el._confirmPanelOpen).toBe(false);
3354
+
3355
+ // Sanity: once composition ends, Escape works as before.
3356
+ el.dispatchEvent(
3357
+ new globalThis.KeyboardEvent("keydown", {
3358
+ key: "Escape",
3359
+ bubbles: true,
3360
+ }),
3361
+ );
3362
+ await el.updateComplete;
3363
+ expect(requestCloseSpy).toHaveBeenCalledTimes(1);
3364
+ });
3365
+
3322
3366
  it("still closes normally after file picker selection", async () => {
3323
3367
  const el = await createElement();
3324
3368
  const editor = requireElement(
@@ -1245,6 +1245,7 @@ export class JantCollectionsManager extends LitElement {
1245
1245
  (e.currentTarget as HTMLInputElement).value,
1246
1246
  )}
1247
1247
  @keydown=${(e: globalThis.KeyboardEvent) => {
1248
+ if (e.isComposing || e.keyCode === 229) return;
1248
1249
  const target = e.currentTarget as HTMLInputElement;
1249
1250
  if (e.key === "Enter") {
1250
1251
  e.preventDefault();
@@ -102,6 +102,7 @@ export class JantCollectionForm extends LitElement {
102
102
  connectedCallback() {
103
103
  super.connectedCallback();
104
104
  this.#boundKeydown = (e: KeyboardEvent) => {
105
+ if (e.isComposing || e.keyCode === 229) return;
105
106
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
106
107
  e.preventDefault();
107
108
  void this.#handleSubmit(e);
@@ -386,6 +386,10 @@ export class JantCommandPalette extends LitElement {
386
386
  };
387
387
 
388
388
  #handleKeydown = (event: globalThis.KeyboardEvent) => {
389
+ // Let IME consume keys during composition — Enter commits the candidate,
390
+ // Escape dismisses the candidate popup, arrows pick candidates.
391
+ if (event.isComposing || event.keyCode === 229) return;
392
+
389
393
  const items = this.#displayItems;
390
394
 
391
395
  if (event.key === "ArrowDown") {
@@ -2269,6 +2269,10 @@ export class JantComposeDialog extends LitElement {
2269
2269
 
2270
2270
  private _handleDialogCancel = (e: Event) => {
2271
2271
  e.preventDefault();
2272
+ // Defensive: some browsers dispatch <dialog> `cancel` for Escape even
2273
+ // when the IME consumed it. Mirror the guard from _handleKeydown.
2274
+ const ke = e as Partial<globalThis.KeyboardEvent>;
2275
+ if (ke.isComposing || ke.keyCode === 229) return;
2272
2276
  if (this._shouldIgnoreEscapeClose()) return;
2273
2277
  if (this._dismissEscapeOverlay()) return;
2274
2278
  this.requestClose();
@@ -2411,6 +2415,10 @@ export class JantComposeDialog extends LitElement {
2411
2415
 
2412
2416
  private _handleKeydown = (e: Event) => {
2413
2417
  const ke = e as globalThis.KeyboardEvent;
2418
+ // Let IME consume keys during composition (e.g. CJK candidate selection).
2419
+ // Without this, pressing Escape to dismiss the IME popup would trigger the
2420
+ // "Save to drafts?" prompt. See GitHub issue #120.
2421
+ if (ke.isComposing || ke.keyCode === 229) return;
2414
2422
  if (ke.key !== "Escape") {
2415
2423
  this._clearFilePickerEscapeState();
2416
2424
  }
@@ -1736,6 +1736,7 @@ export class JantComposeEditor extends LitElement {
1736
1736
  @input=${(e: Event) => this._onInput("_title", e)}
1737
1737
  @focus=${(e: Event) => this._onFieldFocus(e)}
1738
1738
  @keydown=${(e: globalThis.KeyboardEvent) => {
1739
+ if (e.isComposing || e.keyCode === 229) return;
1739
1740
  if (e.key === "Enter") {
1740
1741
  e.preventDefault();
1741
1742
  this._editor?.commands.focus("start");
@@ -188,6 +188,8 @@ export class JantComposeFullscreen extends LitElement {
188
188
 
189
189
  private _onDocumentKeydown = (e: globalThis.KeyboardEvent) => {
190
190
  if (!this._open || e.key !== "Escape") return;
191
+ // Let IME consume Escape during composition (e.g. CJK candidate dismiss).
192
+ if (e.isComposing || e.keyCode === 229) return;
191
193
  if (this._hasActiveEscapeOverlay()) return;
192
194
 
193
195
  e.preventDefault();
@@ -273,6 +275,7 @@ export class JantComposeFullscreen extends LitElement {
273
275
  this._title = (e.target as HTMLInputElement).value;
274
276
  }}
275
277
  @keydown=${(e: globalThis.KeyboardEvent) => {
278
+ if (e.isComposing || e.keyCode === 229) return;
276
279
  if (e.key === "Enter") {
277
280
  e.preventDefault();
278
281
  this._editor?.commands.focus("start");
@@ -103,6 +103,10 @@ export class JantNavManager extends LitElement {
103
103
  if (!("key" in event) || event.key !== "Escape" || !this._showPreviewMore) {
104
104
  return;
105
105
  }
106
+ // Defensive: nav editor has many text inputs; let IME swallow Escape
107
+ // when the user is dismissing a CJK candidate popup.
108
+ const ke = event as globalThis.KeyboardEvent;
109
+ if (ke.isComposing || ke.keyCode === 229) return;
106
110
 
107
111
  event.preventDefault();
108
112
  this.#closePreviewMore();
@@ -169,6 +169,9 @@ export class JantPostMenu extends LitElement {
169
169
 
170
170
  #handleKeydown = (e: Event) => {
171
171
  const ke = e as globalThis.KeyboardEvent;
172
+ // Let IME consume Escape during composition (e.g. dismissing the CJK
173
+ // candidate popup in the collection search input).
174
+ if (ke.isComposing || ke.keyCode === 229) return;
172
175
  if (ke.key === "Escape") {
173
176
  if (this._addCollectionPanelOpen) {
174
177
  this.#closeAddCollectionPanel();
@@ -418,6 +418,9 @@ export class JantRepoPicker extends LitElement {
418
418
 
419
419
  #handleEscape = (e: KeyboardEvent) => {
420
420
  if (e.key !== "Escape") return;
421
+ // Let IME consume Escape during composition (e.g. dismissing the CJK
422
+ // candidate popup in the owner/repo search inputs).
423
+ if (e.isComposing || e.keyCode === 229) return;
421
424
  if (this._ownerOpen || this._repoOpen) {
422
425
  this._ownerOpen = false;
423
426
  this._repoOpen = false;
@@ -450,6 +450,7 @@ export class JantSettingsGeneral extends LitElement {
450
450
  };
451
451
 
452
452
  private _onLocalePickerKeydown = (e: KeyboardEvent) => {
453
+ if (e.isComposing || e.keyCode === 229) return;
453
454
  if (e.key === "Escape" && this._localeOpen) {
454
455
  this._localeOpen = false;
455
456
  this._localeQuery = "";
@@ -614,6 +615,8 @@ export class JantSettingsGeneral extends LitElement {
614
615
  dirty: boolean,
615
616
  loading: boolean,
616
617
  ) {
618
+ // Pressing Enter to commit an IME candidate must not also submit the form.
619
+ if (e.isComposing || e.keyCode === 229) return;
617
620
  if (
618
621
  e.key === "Enter" &&
619
622
  !loading &&
@@ -32,6 +32,60 @@ export function getJsonNumber(value: unknown, key: string): number | undefined {
32
32
  export async function readJsonObject(
33
33
  response: Response,
34
34
  ): Promise<Record<string, unknown>> {
35
- const data = await response.json();
36
- return isJsonObject(data) ? data : {};
35
+ const text = await response.text();
36
+ if (!text.trim()) return {};
37
+ try {
38
+ const data = JSON.parse(text);
39
+ return isJsonObject(data) ? data : {};
40
+ } catch {
41
+ throw new Error(
42
+ `Expected JSON (HTTP ${response.status}) but got: ${truncate(text.trim(), 200)}`,
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Read a server error message from a failed Response.
49
+ * Prefers JSON `{ error }`, falls back to the raw text body so server-side
50
+ * failures (e.g. plain-text 404 from an edge/proxy) reach the user instead of
51
+ * being masked by a cryptic JSON parse error.
52
+ */
53
+ export async function readErrorMessage(
54
+ response: Response,
55
+ fallback: string,
56
+ ): Promise<string> {
57
+ let text: string;
58
+ try {
59
+ text = await response.text();
60
+ } catch {
61
+ return fallback;
62
+ }
63
+ return extractErrorMessage(text, fallback);
64
+ }
65
+
66
+ /** Same as readErrorMessage but for a body already read as text (e.g. XHR). */
67
+ export function readErrorMessageFromText(
68
+ text: string,
69
+ fallback: string,
70
+ ): string {
71
+ return extractErrorMessage(text, fallback);
72
+ }
73
+
74
+ function extractErrorMessage(text: string, fallback: string): string {
75
+ const trimmed = text.trim();
76
+ if (!trimmed) return fallback;
77
+ try {
78
+ const data = JSON.parse(trimmed);
79
+ if (isJsonObject(data)) {
80
+ const msg = getJsonString(data, "error");
81
+ if (msg) return msg;
82
+ }
83
+ } catch {
84
+ // Not JSON — fall through and surface the raw text below.
85
+ }
86
+ return truncate(trimmed, 200);
87
+ }
88
+
89
+ function truncate(value: string, max: number): string {
90
+ return value.length > max ? `${value.slice(0, max)}…` : value;
37
91
  }
@@ -45,7 +45,7 @@ export async function extractImageMetadata(
45
45
 
46
46
  /**
47
47
  * Extract metadata from a video file.
48
- * Loads the video to get dimensions, then seeks to `min(duration * 0.1, 3)` and
48
+ * Loads the video to get dimensions, then seeks to `min(duration * 0.1, 1)` and
49
49
  * captures a frame for blurhash (32px canvas) and a poster image (640px WebP).
50
50
  * Uses an 8s timeout — returns only dimensions if capture times out.
51
51
  */
@@ -81,7 +81,7 @@ export async function extractVideoMetadata(file: File): Promise<{
81
81
  let blurhash: string | undefined;
82
82
  let poster: Blob | undefined;
83
83
  try {
84
- const seekTime = Math.min(duration * 0.1, 3);
84
+ const seekTime = Math.min(duration * 0.1, 1);
85
85
  const result = await Promise.race([
86
86
  captureVideoFrameAndPoster(video, width, height, seekTime),
87
87
  timeout(8000),
@@ -6,7 +6,12 @@
6
6
  * when a file is larger than MULTIPART_THRESHOLD.
7
7
  */
8
8
 
9
- import { getJsonNumber, getJsonString, readJsonObject } from "./json.js";
9
+ import {
10
+ getJsonNumber,
11
+ getJsonString,
12
+ readErrorMessage,
13
+ readJsonObject,
14
+ } from "./json.js";
10
15
 
11
16
  /** Files at or above this size use multipart upload (95MB, below 100MB Worker limit) */
12
17
  export const MULTIPART_THRESHOLD = 95 * 1024 * 1024;
@@ -59,8 +64,7 @@ export async function uploadMultipart(
59
64
  });
60
65
 
61
66
  if (!initRes.ok) {
62
- const data = await readJsonObject(initRes);
63
- throw new Error(getJsonString(data, "error") ?? "Failed to start upload");
67
+ throw new Error(await readErrorMessage(initRes, "Failed to start upload"));
64
68
  }
65
69
 
66
70
  const initData = await readJsonObject(initRes);
@@ -87,7 +91,9 @@ export async function uploadMultipart(
87
91
  });
88
92
 
89
93
  if (!posterRes.ok) {
90
- throw new Error("Failed to upload poster");
94
+ throw new Error(
95
+ await readErrorMessage(posterRes, "Failed to upload poster"),
96
+ );
91
97
  }
92
98
 
93
99
  const posterData = await readJsonObject(posterRes);
@@ -118,7 +124,12 @@ export async function uploadMultipart(
118
124
  );
119
125
 
120
126
  if (!partRes.ok) {
121
- throw new Error(`Failed to upload part ${partNumber}`);
127
+ throw new Error(
128
+ await readErrorMessage(
129
+ partRes,
130
+ `Failed to upload part ${partNumber}`,
131
+ ),
132
+ );
122
133
  }
123
134
 
124
135
  const partData = await readJsonObject(partRes);
@@ -155,9 +166,8 @@ export async function uploadMultipart(
155
166
  });
156
167
 
157
168
  if (!completeRes.ok) {
158
- const data = await readJsonObject(completeRes);
159
169
  throw new Error(
160
- getJsonString(data, "error") ?? "Failed to complete upload",
170
+ await readErrorMessage(completeRes, "Failed to complete upload"),
161
171
  );
162
172
  }
163
173
 
@@ -2,6 +2,8 @@ import {
2
2
  getJsonNumber,
3
3
  getJsonString,
4
4
  isJsonObject,
5
+ readErrorMessage,
6
+ readErrorMessageFromText,
5
7
  readJsonObject,
6
8
  } from "./json.js";
7
9
  import { publicPath } from "./runtime-paths.js";
@@ -155,8 +157,7 @@ async function initiateUpload(file: File): Promise<InitiateResponse> {
155
157
  });
156
158
 
157
159
  if (!res.ok) {
158
- const data = await readJsonObject(res);
159
- throw new Error(getJsonString(data, "error") ?? "Failed to start upload");
160
+ throw new Error(await readErrorMessage(res, "Failed to start upload"));
160
161
  }
161
162
 
162
163
  const data = await readJsonObject(res);
@@ -176,7 +177,9 @@ async function uploadPoster(uploadId: string, poster: Blob): Promise<void> {
176
177
  body: poster,
177
178
  });
178
179
  if (!response.ok) {
179
- throw new Error("Failed to upload poster");
180
+ throw new Error(
181
+ await readErrorMessage(response, "Failed to upload poster"),
182
+ );
180
183
  }
181
184
  }
182
185
 
@@ -202,9 +205,8 @@ async function completeUpload(
202
205
  );
203
206
 
204
207
  if (!completeRes.ok) {
205
- const data = await readJsonObject(completeRes);
206
208
  throw new Error(
207
- getJsonString(data, "error") ?? "Failed to complete upload",
209
+ await readErrorMessage(completeRes, "Failed to complete upload"),
208
210
  );
209
211
  }
210
212
 
@@ -246,7 +248,12 @@ async function uploadMultipartRelay(
246
248
  },
247
249
  );
248
250
  if (!response.ok) {
249
- throw new Error(`Failed to upload part ${partNumber}`);
251
+ throw new Error(
252
+ readErrorMessageFromText(
253
+ response.text,
254
+ `Failed to upload part ${partNumber}`,
255
+ ),
256
+ );
250
257
  }
251
258
  const data = parseJsonObjectFromText(response.text);
252
259
  const uploadedPart = data ? getJsonNumber(data, "partNumber") : null;
@@ -278,7 +285,9 @@ export async function uploadViaSession(
278
285
  onProgress,
279
286
  );
280
287
  if (!response.ok) {
281
- throw new Error("Upload failed");
288
+ throw new Error(
289
+ readErrorMessageFromText(response.text, "Upload failed"),
290
+ );
282
291
  }
283
292
  onProgress?.(1);
284
293
  } else if (transport.kind === "relay") {
@@ -289,9 +298,8 @@ export async function uploadViaSession(
289
298
  onProgress,
290
299
  );
291
300
  if (!response.ok) {
292
- const data = parseJsonObjectFromText(response.text);
293
301
  throw new Error(
294
- (data && getJsonString(data, "error")) ?? "Upload failed",
302
+ readErrorMessageFromText(response.text, "Upload failed"),
295
303
  );
296
304
  }
297
305
  onProgress?.(1);
@@ -56,7 +56,7 @@ function isSupported(): boolean {
56
56
 
57
57
  /**
58
58
  * Extract a poster frame, blurhash, and source dimensions from a video file.
59
- * Seeks to `min(duration × 0.1, 3s)` and captures the frame.
59
+ * Seeks to `min(duration × 0.1, 1s)` and captures the frame.
60
60
  * Also returns the original video dimensions so the caller can compute
61
61
  * the correct output size without opening a second Input instance.
62
62
  *
@@ -85,7 +85,7 @@ async function extractPoster(file: File): Promise<{
85
85
 
86
86
  const duration = await input.computeDuration();
87
87
  const durationSeconds = normalizeDurationSeconds(duration);
88
- const seekTime = Math.min(duration * 0.1, 3);
88
+ const seekTime = Math.min(duration * 0.1, 1);
89
89
 
90
90
  const sink = new CanvasSink(videoTrack);
91
91
  const wrapped = await sink.getCanvas(seekTime);
@@ -60,6 +60,20 @@ const ManagedSiteDomainSchema = z.object({
60
60
  makePrimary: z.boolean().optional(),
61
61
  });
62
62
 
63
+ const RenameManagedSiteSchema = z.object({
64
+ key: ManagedSiteKeySchema,
65
+ primaryHost: z
66
+ .string()
67
+ .trim()
68
+ .toLowerCase()
69
+ .min(1)
70
+ .max(255)
71
+ .regex(
72
+ /^(?=.{1,255}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/,
73
+ "Primary host must be a valid hostname.",
74
+ ),
75
+ });
76
+
63
77
  export const internalSitesRoutes = new Hono<Env>();
64
78
 
65
79
  function assertHostBasedMode(env: Bindings) {
@@ -198,6 +212,25 @@ internalSitesRoutes.post(
198
212
  },
199
213
  );
200
214
 
215
+ internalSitesRoutes.post(
216
+ "/:siteId/rename",
217
+ requireInternalAdminApi(),
218
+ async (c) => {
219
+ assertHostBasedMode(c.env);
220
+ const body = parseValidated(RenameManagedSiteSchema, await c.req.json());
221
+ const result = await c.var.services.siteAdmin.renameManagedSite(
222
+ c.req.param("siteId"),
223
+ body,
224
+ );
225
+
226
+ return c.json({
227
+ primaryHost: result.domain.host,
228
+ siteId: result.site.id,
229
+ status: result.site.status,
230
+ });
231
+ },
232
+ );
233
+
201
234
  internalSitesRoutes.get(
202
235
  "/:siteId/domains",
203
236
  requireInternalAdminApi(),