@tomorrowos/sdk 0.9.51 → 0.9.53

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/LOVABLE_SETUP.md CHANGED
@@ -347,7 +347,7 @@ Copy the same keys to the **Node host** env.
347
347
  "start": "tsx server.ts"
348
348
  },
349
349
  "dependencies": {
350
- "@tomorrowos/sdk": "^0.9.51",
350
+ "@tomorrowos/sdk": "^0.9.53",
351
351
  "dotenv": "^17.2.3",
352
352
  "tsx": "^4.19.0"
353
353
  }
@@ -1,6 +1,6 @@
1
1
  # Vercel / v0 閳?Verbatim question bank
2
2
 
3
- > **Protocol:** `vercel-questions/1.0` (pairs with `VERCEL_SETUP.md` 1.9+ / `@tomorrowos/sdk` 0.9.51+)
3
+ > **Protocol:** `vercel-questions/1.0` (pairs with `VERCEL_SETUP.md` 1.9+ / `@tomorrowos/sdk` 0.9.53+)
4
4
  > **For Agents:** When asking the user a setup question, **copy-paste the block below for that step EXACTLY**.
5
5
  > Do **not** paraphrase, reorder, rename options, or invent alternatives (no S3, no Supabase Storage, no 閳ユ窂lob recommended閳? no 閳ユ竸isable media閳?.
6
6
  > After each answer, configure per `VERCEL_SETUP.md`, then paste the **next** block.
package/VERCEL_SETUP.md CHANGED
@@ -5,7 +5,7 @@
5
5
  >
6
6
  > **On Vercel, this file is the setup questionnaire** (not `REPLIT_SETUP.md`, not `LLM_PROMPT.md`).
7
7
  >
8
- > Protocol id: `vercel-setup/1.9` — pairs with `@tomorrowos/sdk` **0.9.51+** (`templates/cms-starter-v0`, Fluid Functions WebSockets).
8
+ > Protocol id: `vercel-setup/1.9` — pairs with `@tomorrowos/sdk` **0.9.53+** (`templates/cms-starter-v0`, Fluid Functions WebSockets).
9
9
  >
10
10
  > **Questions source of truth:** `VERCEL_QUESTIONS.md` ? Agents must **open that file and paste each Q block verbatim**. Do not paraphrase.
11
11
  > **Copy questions verbatim from `VERCEL_QUESTIONS.md`.** Do not invent alternate Q2 options (no ?Supabase Storage?? no S3, no ?disable media?? no making Blob the recommended option).
@@ -11,6 +11,23 @@ export interface CloudinaryUploadResult {
11
11
  bytes?: number;
12
12
  }
13
13
  export declare function resolveCloudinaryConfig(env?: NodeJS.ProcessEnv): CloudinaryStorageConfig | null;
14
+ /** Browser direct-upload params (signed). Secret never leaves the server. */
15
+ export declare function createSignedUploadParams(config: CloudinaryStorageConfig, options: {
16
+ publicId: string;
17
+ filename: string;
18
+ }): {
19
+ cloudName: string;
20
+ apiKey: string;
21
+ timestamp: number;
22
+ signature: string;
23
+ publicId: string;
24
+ filenameOverride: string;
25
+ uploadUrl: string;
26
+ };
27
+ export declare function fetchCloudinaryResource(config: CloudinaryStorageConfig, publicId: string, resourceType?: string): Promise<CloudinaryUploadResult & {
28
+ etag?: string;
29
+ durationSec?: number;
30
+ }>;
14
31
  export declare function uploadBufferToCloudinary(body: Buffer, options: {
15
32
  config: CloudinaryStorageConfig;
16
33
  publicId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"cloudinary-storage.d.ts","sourceRoot":"","sources":["../src/cloudinary-storage.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAOD,wBAAgB,uBAAuB,CACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,uBAAuB,GAAG,IAAI,CAoBhC;AAWD,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;IACP,MAAM,EAAE,uBAAuB,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,GACA,OAAO,CAAC,sBAAsB,CAAC,CAsCjC;AAED,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,uBAAuB,EAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,SAAU,GACrB,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,qEAAqE;AACrE,wBAAsB,cAAc,CAClC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAGf"}
1
+ {"version":3,"file":"cloudinary-storage.d.ts","sourceRoot":"","sources":["../src/cloudinary-storage.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAOD,wBAAgB,uBAAuB,CACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,uBAAuB,GAAG,IAAI,CAoBhC;AAsBD,6EAA6E;AAC7E,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,uBAAuB,EAC/B,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC9C;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;CACnB,CAyBA;AAED,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,uBAAuB,EAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,SAAU,GACrB,OAAO,CAAC,sBAAsB,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA+B3E;AAED,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;IACP,MAAM,EAAE,uBAAuB,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,GACA,OAAO,CAAC,sBAAsB,CAAC,CAoCjC;AAED,wBAAsB,qBAAqB,CACzC,MAAM,EAAE,uBAAuB,EAC/B,QAAQ,EAAE,MAAM,EAChB,YAAY,SAAU,GACrB,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,qEAAqE;AACrE,wBAAsB,cAAc,CAClC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAGf"}
@@ -29,11 +29,73 @@ function configureCloudinary(config) {
29
29
  secure: true
30
30
  });
31
31
  }
32
+ function resolvePublicId(config, publicId) {
33
+ if (!config.folder)
34
+ return publicId;
35
+ const folder = config.folder.replace(/^\/+|\/+$/g, "");
36
+ if (!folder)
37
+ return publicId;
38
+ if (publicId === folder || publicId.startsWith(`${folder}/`))
39
+ return publicId;
40
+ return `${folder}/${publicId}`;
41
+ }
42
+ /** Browser direct-upload params (signed). Secret never leaves the server. */
43
+ export function createSignedUploadParams(config, options) {
44
+ configureCloudinary(config);
45
+ const publicId = resolvePublicId(config, options.publicId);
46
+ const timestamp = Math.round(Date.now() / 1000);
47
+ const paramsToSign = {
48
+ timestamp,
49
+ public_id: publicId,
50
+ overwrite: true,
51
+ unique_filename: false,
52
+ use_filename: false,
53
+ filename_override: options.filename
54
+ };
55
+ const signature = cloudinary.utils.api_sign_request(paramsToSign, config.apiSecret);
56
+ return {
57
+ cloudName: config.cloudName,
58
+ apiKey: config.apiKey,
59
+ timestamp,
60
+ signature,
61
+ publicId,
62
+ filenameOverride: options.filename,
63
+ uploadUrl: `https://api.cloudinary.com/v1_1/${config.cloudName}/auto/upload`
64
+ };
65
+ }
66
+ export async function fetchCloudinaryResource(config, publicId, resourceType = "image") {
67
+ configureCloudinary(config);
68
+ const types = resourceType === "auto"
69
+ ? ["video", "image", "raw"]
70
+ : [resourceType, "video", "image", "raw"];
71
+ let lastErr;
72
+ for (const type of types) {
73
+ try {
74
+ const result = (await cloudinary.api.resource(publicId, {
75
+ resource_type: type
76
+ }));
77
+ return {
78
+ publicId: result.public_id,
79
+ secureUrl: result.secure_url,
80
+ resourceType: result.resource_type || type,
81
+ bytes: result.bytes,
82
+ etag: result.etag,
83
+ durationSec: typeof result.duration === "number" && Number.isFinite(result.duration)
84
+ ? result.duration
85
+ : undefined
86
+ };
87
+ }
88
+ catch (err) {
89
+ lastErr = err;
90
+ }
91
+ }
92
+ throw lastErr instanceof Error
93
+ ? lastErr
94
+ : new Error("Cloudinary asset not found");
95
+ }
32
96
  export async function uploadBufferToCloudinary(body, options) {
33
97
  configureCloudinary(options.config);
34
- const publicId = options.config.folder
35
- ? `${options.config.folder.replace(/^\/+|\/+$/g, "")}/${options.publicId}`
36
- : options.publicId;
98
+ const publicId = resolvePublicId(options.config, options.publicId);
37
99
  const result = await new Promise((resolve, reject) => {
38
100
  const stream = cloudinary.uploader.upload_stream({
39
101
  resource_type: "auto",
@@ -207,6 +207,8 @@ export declare class TomorrowOS extends EventEmitter {
207
207
  private getSuggestedCmsUrl;
208
208
  listen(options: ListenOptions): http.Server;
209
209
  private handleMediaUpload;
210
+ private handleMediaUploadSign;
211
+ private handleMediaRegister;
210
212
  private storeUploadedMediaAsset;
211
213
  private getAssetIdsFromItems;
212
214
  private isUploadedAssetReferenced;
@@ -1 +1 @@
1
- {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
@@ -8,7 +8,7 @@ import { generateDeterministicPairingCode, generateRandomPairingCode, isValidPai
8
8
  import { PlaylistCatalog } from "./playlist-catalog.js";
9
9
  import { MemoryStore } from "./store/memory-store.js";
10
10
  import { resolveBrandLogoPath, syncProjectAssetsToStaticRoot } from "./brand-assets.js";
11
- import { deleteCloudinaryAsset, resolveCloudinaryConfig, uploadBufferToCloudinary } from "./cloudinary-storage.js";
11
+ import { createSignedUploadParams, deleteCloudinaryAsset, fetchCloudinaryResource, resolveCloudinaryConfig, uploadBufferToCloudinary } from "./cloudinary-storage.js";
12
12
  import { probeVideoDurationMs } from "./media-probe.js";
13
13
  import { contentHashHex, storeUploadIfNeeded } from "./upload-storage.js";
14
14
  import { detectPrimaryLanIpv4 } from "./lan-address.js";
@@ -168,9 +168,12 @@ function isAllowedWebSocketPath(pathname, allowed) {
168
168
  const trimmed = p.replace(/\/+$/, "") || "/";
169
169
  return allowed.has(trimmed) || allowed.has(`${trimmed}/`);
170
170
  }
171
- const MAX_MEDIA_UPLOAD_BYTES = 100 * 1024 * 1024;
171
+ /** JSON API bodies stay capped; media uploads have no SDK size cap. */
172
+ const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024;
173
+ /** `null` = no limit (host/Cloudinary may still enforce their own caps). */
174
+ const MAX_MEDIA_UPLOAD_BYTES = null;
172
175
  async function readJsonBody(req) {
173
- const buf = await readRawBody(req, MAX_MEDIA_UPLOAD_BYTES);
176
+ const buf = await readRawBody(req, MAX_JSON_BODY_BYTES);
174
177
  const raw = buf.toString("utf8");
175
178
  if (!raw.trim())
176
179
  return {};
@@ -187,7 +190,7 @@ async function readRawBody(req, maxBytes) {
187
190
  for await (const chunk of req) {
188
191
  const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
189
192
  total += buf.length;
190
- if (total > maxBytes) {
193
+ if (maxBytes != null && maxBytes > 0 && total > maxBytes) {
191
194
  throw new Error(`Body too large (max ${maxBytes} bytes)`);
192
195
  }
193
196
  chunks.push(buf);
@@ -966,6 +969,101 @@ export class TomorrowOS extends EventEmitter {
966
969
  sendJson(res, 400, { status: "failed", error: msg });
967
970
  }
968
971
  }
972
+ async handleMediaUploadSign(req, res, url) {
973
+ const cloudinaryConfig = resolveCloudinaryConfig();
974
+ if (!cloudinaryConfig) {
975
+ sendJson(res, 404, {
976
+ status: "failed",
977
+ error: "Cloudinary is not configured"
978
+ });
979
+ return;
980
+ }
981
+ const rawName = url.searchParams.get("filename") || "upload";
982
+ const safeName = sanitizeUploadFilename(rawName);
983
+ const publicId = randomUUID().replace(/-/g, "");
984
+ const signed = createSignedUploadParams(cloudinaryConfig, {
985
+ publicId,
986
+ filename: safeName
987
+ });
988
+ sendJson(res, 200, {
989
+ status: "success",
990
+ mode: "cloudinary-direct",
991
+ ...signed
992
+ });
993
+ }
994
+ async handleMediaRegister(req, res) {
995
+ const cloudinaryConfig = resolveCloudinaryConfig();
996
+ if (!cloudinaryConfig) {
997
+ sendJson(res, 400, {
998
+ status: "failed",
999
+ error: "Cloudinary is not configured"
1000
+ });
1001
+ return;
1002
+ }
1003
+ try {
1004
+ const body = (await readJsonBody(req));
1005
+ const publicId = String(body.publicId || "").trim();
1006
+ if (!publicId) {
1007
+ sendJson(res, 400, { status: "failed", error: "publicId is required" });
1008
+ return;
1009
+ }
1010
+ const safeName = sanitizeUploadFilename(body.originalFilename || path.basename(publicId) || "upload");
1011
+ const hintedType = String(body.resourceType || "auto").trim() || "auto";
1012
+ const remote = await fetchCloudinaryResource(cloudinaryConfig, publicId, hintedType);
1013
+ const sha256 = remote.etag
1014
+ ? `cloudinary-etag:${remote.etag}`
1015
+ : `cloudinary:${remote.publicId}`;
1016
+ const existing = await this.store.getUploadedAssetBySha256(sha256);
1017
+ if (existing && isRemoteUploadedAssetUrl(existing.url)) {
1018
+ const payload = {
1019
+ status: "success",
1020
+ url: existing.url,
1021
+ assetId: existing.id,
1022
+ filename: safeName,
1023
+ size: existing.bytes,
1024
+ contentHash: existing.sha256,
1025
+ deduplicated: true
1026
+ };
1027
+ if (remote.durationSec != null) {
1028
+ payload.durationMs = Math.round(remote.durationSec * 1000);
1029
+ }
1030
+ sendJson(res, 200, payload);
1031
+ return;
1032
+ }
1033
+ const now = new Date().toISOString();
1034
+ const mimeType = typeof body.mimeType === "string" ? body.mimeType : undefined;
1035
+ const asset = {
1036
+ id: randomUUID(),
1037
+ sha256,
1038
+ storageKey: remote.publicId,
1039
+ url: remote.secureUrl,
1040
+ originalFilename: safeName,
1041
+ mimeType,
1042
+ resourceType: remote.resourceType || inferResourceType(mimeType, safeName),
1043
+ bytes: remote.bytes,
1044
+ createdAt: now,
1045
+ updatedAt: now
1046
+ };
1047
+ await this.store.setUploadedAsset(asset);
1048
+ const payload = {
1049
+ status: "success",
1050
+ url: asset.url,
1051
+ assetId: asset.id,
1052
+ filename: safeName,
1053
+ size: asset.bytes,
1054
+ contentHash: asset.sha256,
1055
+ deduplicated: false
1056
+ };
1057
+ if (remote.durationSec != null) {
1058
+ payload.durationMs = Math.round(remote.durationSec * 1000);
1059
+ }
1060
+ sendJson(res, 200, payload);
1061
+ }
1062
+ catch (e) {
1063
+ const msg = e instanceof Error ? e.message : "Register failed";
1064
+ sendJson(res, 400, { status: "failed", error: msg });
1065
+ }
1066
+ }
969
1067
  async storeUploadedMediaAsset(body, safeName, mimeType) {
970
1068
  const sha256 = contentHashHex(body);
971
1069
  const cloudinaryConfig = resolveCloudinaryConfig();
@@ -1266,6 +1364,14 @@ export class TomorrowOS extends EventEmitter {
1266
1364
  await this.handleMediaUpload(req, res, url);
1267
1365
  return;
1268
1366
  }
1367
+ if (req.method === "GET" && pathname === "/media/upload-sign") {
1368
+ await this.handleMediaUploadSign(req, res, url);
1369
+ return;
1370
+ }
1371
+ if (req.method === "POST" && pathname === "/media/register") {
1372
+ await this.handleMediaRegister(req, res);
1373
+ return;
1374
+ }
1269
1375
  if (req.method === "GET" && this.staticRoot) {
1270
1376
  const served = await this.tryServeStatic(pathname, res);
1271
1377
  if (served)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "0.9.51",
3
+ "version": "0.9.53",
4
4
  "description": "TomorrowOS CMS server SDK - WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-cms",
3
- "version": "0.9.51",
3
+ "version": "0.9.53",
4
4
  "description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
5
5
  "private": true,
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "build-player": "tomorrowos build --platform tizen"
14
14
  },
15
15
  "dependencies": {
16
- "@tomorrowos/sdk": "^0.9.51",
16
+ "@tomorrowos/sdk": "^0.9.53",
17
17
  "dotenv": "^17.2.3",
18
18
  "tsx": "^4.19.0"
19
19
  },
@@ -43,6 +43,10 @@ let uploadInProgress = false;
43
43
  const UPLOAD_MAX_RETRIES = 3;
44
44
  const DEVICE_RECONNECT_GRACE_MS = 60000;
45
45
  const UPLOAD_TIMEOUT_MS = 120000;
46
+ /** Direct-to-Cloudinary: no XHR timeout (large videos can exceed 2 minutes). */
47
+ const CLOUDINARY_DIRECT_TIMEOUT_MS = 0;
48
+ /** Cloudinary chunked upload minimum is 5MB; use 8MB chunks. */
49
+ const CLOUDINARY_CHUNK_BYTES = 8 * 1024 * 1024;
46
50
 
47
51
  /**
48
52
  * Apply brand.json to the Control Panel (name + colours). No logo on the panel.
@@ -1661,7 +1665,164 @@ function uploadFileWithProgress(file, { onProgress, attempt }) {
1661
1665
  });
1662
1666
  }
1663
1667
 
1668
+ async function fetchCloudinaryUploadSign(filename) {
1669
+ const q = new URLSearchParams({ filename: filename || "upload" });
1670
+ const res = await fetch(`/media/upload-sign?${q.toString()}`, {
1671
+ cache: "no-store"
1672
+ });
1673
+ if (res.status === 404) return null;
1674
+ let payload = {};
1675
+ try {
1676
+ payload = await res.json();
1677
+ } catch {
1678
+ payload = {};
1679
+ }
1680
+ if (!res.ok || payload.status === "failed" || !payload.uploadUrl) {
1681
+ if (res.status === 404) return null;
1682
+ throw new Error(payload.error || `Upload sign failed (${res.status})`);
1683
+ }
1684
+ return payload;
1685
+ }
1686
+
1687
+ function buildCloudinaryFormData(fileOrBlob, sign) {
1688
+ const form = new FormData();
1689
+ const name = String(sign.filenameOverride || fileOrBlob.name || "upload");
1690
+ form.append("file", fileOrBlob, name);
1691
+ form.append("api_key", String(sign.apiKey));
1692
+ form.append("timestamp", String(sign.timestamp));
1693
+ form.append("signature", String(sign.signature));
1694
+ form.append("public_id", String(sign.publicId));
1695
+ form.append("overwrite", "true");
1696
+ form.append("unique_filename", "false");
1697
+ form.append("use_filename", "false");
1698
+ form.append("filename_override", name);
1699
+ return form;
1700
+ }
1701
+
1702
+ function xhrSendForm(url, form, { timeoutMs, onProgress, headers }) {
1703
+ return new Promise((resolve, reject) => {
1704
+ const xhr = new XMLHttpRequest();
1705
+ xhr.open("POST", url, true);
1706
+ xhr.timeout = timeoutMs;
1707
+ if (headers) {
1708
+ for (const [key, value] of Object.entries(headers)) {
1709
+ xhr.setRequestHeader(key, value);
1710
+ }
1711
+ }
1712
+ xhr.upload.onprogress = (event) => {
1713
+ if (!event.lengthComputable) return;
1714
+ if (typeof onProgress === "function") onProgress(event.loaded, event.total);
1715
+ };
1716
+ xhr.onerror = () => reject(new Error("Network error uploading to Cloudinary"));
1717
+ xhr.ontimeout = () => reject(new Error("Cloudinary upload timed out"));
1718
+ xhr.onload = () => {
1719
+ let payload = {};
1720
+ try {
1721
+ payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
1722
+ } catch {
1723
+ payload = {};
1724
+ }
1725
+ if (xhr.status >= 200 && xhr.status < 300 && !payload.error) {
1726
+ resolve(payload);
1727
+ return;
1728
+ }
1729
+ const msg =
1730
+ payload.error?.message ||
1731
+ payload.error ||
1732
+ `Cloudinary upload failed (${xhr.status})`;
1733
+ reject(new Error(typeof msg === "string" ? msg : JSON.stringify(msg)));
1734
+ };
1735
+ xhr.send(form);
1736
+ });
1737
+ }
1738
+
1739
+ async function uploadToCloudinaryDirect(file, sign, onProgress) {
1740
+ const uploadUrl = String(sign.uploadUrl);
1741
+ const uniqueUploadId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
1742
+
1743
+ if (file.size <= CLOUDINARY_CHUNK_BYTES) {
1744
+ const form = buildCloudinaryFormData(file, sign);
1745
+ const result = await xhrSendForm(uploadUrl, form, {
1746
+ timeoutMs: CLOUDINARY_DIRECT_TIMEOUT_MS,
1747
+ onProgress: (loaded, total) => {
1748
+ if (typeof onProgress !== "function") return;
1749
+ onProgress(Math.round((loaded / total) * 100));
1750
+ }
1751
+ });
1752
+ return result;
1753
+ }
1754
+
1755
+ let offset = 0;
1756
+ let finalResult = null;
1757
+ while (offset < file.size) {
1758
+ const end = Math.min(offset + CLOUDINARY_CHUNK_BYTES, file.size);
1759
+ const chunk = file.slice(offset, end);
1760
+ const form = buildCloudinaryFormData(chunk, sign);
1761
+ const chunkBase = offset;
1762
+ // eslint-disable-next-line no-await-in-loop
1763
+ finalResult = await xhrSendForm(uploadUrl, form, {
1764
+ timeoutMs: CLOUDINARY_DIRECT_TIMEOUT_MS,
1765
+ headers: {
1766
+ "X-Unique-Upload-Id": uniqueUploadId,
1767
+ "Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
1768
+ },
1769
+ onProgress: (loaded) => {
1770
+ if (typeof onProgress !== "function") return;
1771
+ const overall = Math.min(file.size, chunkBase + loaded);
1772
+ onProgress(Math.round((overall / file.size) * 100));
1773
+ }
1774
+ });
1775
+ offset = end;
1776
+ }
1777
+ return finalResult;
1778
+ }
1779
+
1780
+ async function registerCloudinaryUpload(file, cloudinaryResult) {
1781
+ const res = await fetch("/media/register", {
1782
+ method: "POST",
1783
+ headers: { "Content-Type": "application/json" },
1784
+ body: JSON.stringify({
1785
+ publicId: cloudinaryResult.public_id,
1786
+ resourceType: cloudinaryResult.resource_type || "auto",
1787
+ originalFilename: file.name,
1788
+ mimeType: file.type || undefined
1789
+ })
1790
+ });
1791
+ let payload = {};
1792
+ try {
1793
+ payload = await res.json();
1794
+ } catch {
1795
+ payload = {};
1796
+ }
1797
+ if (!res.ok || payload.status === "failed") {
1798
+ throw new Error(payload.error || `Register failed (${res.status})`);
1799
+ }
1800
+ return payload;
1801
+ }
1802
+
1803
+ async function uploadViaCloudinaryDirect(file, onProgress) {
1804
+ const sign = await fetchCloudinaryUploadSign(file.name);
1805
+ if (!sign) return null;
1806
+ const cloudinaryResult = await uploadToCloudinaryDirect(file, sign, onProgress);
1807
+ if (!cloudinaryResult?.public_id) {
1808
+ throw new Error("Cloudinary upload returned no public_id");
1809
+ }
1810
+ if (typeof onProgress === "function") onProgress(100);
1811
+ return registerCloudinaryUpload(file, cloudinaryResult);
1812
+ }
1813
+
1664
1814
  async function uploadFile(file, onProgress) {
1815
+ try {
1816
+ const direct = await uploadViaCloudinaryDirect(file, onProgress);
1817
+ if (direct) return direct;
1818
+ } catch (err) {
1819
+ const msg = err?.message || String(err);
1820
+ // Fall back to proxy upload only when Cloudinary is unavailable.
1821
+ if (!/not configured|Upload sign failed \(404\)/i.test(msg)) {
1822
+ throw err;
1823
+ }
1824
+ }
1825
+
1665
1826
  let lastErr = null;
1666
1827
  for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt += 1) {
1667
1828
  try {
@@ -1669,6 +1830,9 @@ async function uploadFile(file, onProgress) {
1669
1830
  return payload;
1670
1831
  } catch (err) {
1671
1832
  lastErr = err;
1833
+ const msg = err?.message || String(err);
1834
+ // 413 from host proxy will not succeed on retry — fail fast.
1835
+ if (/Upload failed \(413\)/i.test(msg)) break;
1672
1836
  if (attempt >= UPLOAD_MAX_RETRIES) break;
1673
1837
  await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
1674
1838
  }
@@ -43,6 +43,10 @@ let uploadInProgress = false;
43
43
  const UPLOAD_MAX_RETRIES = 3;
44
44
  const DEVICE_RECONNECT_GRACE_MS = 60000;
45
45
  const UPLOAD_TIMEOUT_MS = 120000;
46
+ /** Direct-to-Cloudinary: no XHR timeout (large videos can exceed 2 minutes). */
47
+ const CLOUDINARY_DIRECT_TIMEOUT_MS = 0;
48
+ /** Cloudinary chunked upload minimum is 5MB; use 8MB chunks. */
49
+ const CLOUDINARY_CHUNK_BYTES = 8 * 1024 * 1024;
46
50
 
47
51
  /**
48
52
  * Apply brand.json to the Control Panel (name + colours). No logo on the panel.
@@ -1662,7 +1666,162 @@ function uploadFileWithProgress(file, { onProgress, attempt }) {
1662
1666
  });
1663
1667
  }
1664
1668
 
1669
+ async function fetchCloudinaryUploadSign(filename) {
1670
+ const q = new URLSearchParams({ filename: filename || "upload" });
1671
+ const res = await fetch(`/media/upload-sign?${q.toString()}`, {
1672
+ cache: "no-store"
1673
+ });
1674
+ if (res.status === 404) return null;
1675
+ let payload = {};
1676
+ try {
1677
+ payload = await res.json();
1678
+ } catch {
1679
+ payload = {};
1680
+ }
1681
+ if (!res.ok || payload.status === "failed" || !payload.uploadUrl) {
1682
+ if (res.status === 404) return null;
1683
+ throw new Error(payload.error || `Upload sign failed (${res.status})`);
1684
+ }
1685
+ return payload;
1686
+ }
1687
+
1688
+ function buildCloudinaryFormData(fileOrBlob, sign) {
1689
+ const form = new FormData();
1690
+ const name = String(sign.filenameOverride || fileOrBlob.name || "upload");
1691
+ form.append("file", fileOrBlob, name);
1692
+ form.append("api_key", String(sign.apiKey));
1693
+ form.append("timestamp", String(sign.timestamp));
1694
+ form.append("signature", String(sign.signature));
1695
+ form.append("public_id", String(sign.publicId));
1696
+ form.append("overwrite", "true");
1697
+ form.append("unique_filename", "false");
1698
+ form.append("use_filename", "false");
1699
+ form.append("filename_override", name);
1700
+ return form;
1701
+ }
1702
+
1703
+ function xhrSendForm(url, form, { timeoutMs, onProgress, headers }) {
1704
+ return new Promise((resolve, reject) => {
1705
+ const xhr = new XMLHttpRequest();
1706
+ xhr.open("POST", url, true);
1707
+ xhr.timeout = timeoutMs;
1708
+ if (headers) {
1709
+ for (const [key, value] of Object.entries(headers)) {
1710
+ xhr.setRequestHeader(key, value);
1711
+ }
1712
+ }
1713
+ xhr.upload.onprogress = (event) => {
1714
+ if (!event.lengthComputable) return;
1715
+ if (typeof onProgress === "function") onProgress(event.loaded, event.total);
1716
+ };
1717
+ xhr.onerror = () => reject(new Error("Network error uploading to Cloudinary"));
1718
+ xhr.ontimeout = () => reject(new Error("Cloudinary upload timed out"));
1719
+ xhr.onload = () => {
1720
+ let payload = {};
1721
+ try {
1722
+ payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
1723
+ } catch {
1724
+ payload = {};
1725
+ }
1726
+ if (xhr.status >= 200 && xhr.status < 300 && !payload.error) {
1727
+ resolve(payload);
1728
+ return;
1729
+ }
1730
+ const msg =
1731
+ payload.error?.message ||
1732
+ payload.error ||
1733
+ `Cloudinary upload failed (${xhr.status})`;
1734
+ reject(new Error(typeof msg === "string" ? msg : JSON.stringify(msg)));
1735
+ };
1736
+ xhr.send(form);
1737
+ });
1738
+ }
1739
+
1740
+ async function uploadToCloudinaryDirect(file, sign, onProgress) {
1741
+ const uploadUrl = String(sign.uploadUrl);
1742
+ const uniqueUploadId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
1743
+
1744
+ if (file.size <= CLOUDINARY_CHUNK_BYTES) {
1745
+ const form = buildCloudinaryFormData(file, sign);
1746
+ const result = await xhrSendForm(uploadUrl, form, {
1747
+ timeoutMs: CLOUDINARY_DIRECT_TIMEOUT_MS,
1748
+ onProgress: (loaded, total) => {
1749
+ if (typeof onProgress !== "function") return;
1750
+ onProgress(Math.round((loaded / total) * 100));
1751
+ }
1752
+ });
1753
+ return result;
1754
+ }
1755
+
1756
+ let offset = 0;
1757
+ let finalResult = null;
1758
+ while (offset < file.size) {
1759
+ const end = Math.min(offset + CLOUDINARY_CHUNK_BYTES, file.size);
1760
+ const chunk = file.slice(offset, end);
1761
+ const form = buildCloudinaryFormData(chunk, sign);
1762
+ const chunkBase = offset;
1763
+ finalResult = await xhrSendForm(uploadUrl, form, {
1764
+ timeoutMs: CLOUDINARY_DIRECT_TIMEOUT_MS,
1765
+ headers: {
1766
+ "X-Unique-Upload-Id": uniqueUploadId,
1767
+ "Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
1768
+ },
1769
+ onProgress: (loaded) => {
1770
+ if (typeof onProgress !== "function") return;
1771
+ const overall = Math.min(file.size, chunkBase + loaded);
1772
+ onProgress(Math.round((overall / file.size) * 100));
1773
+ }
1774
+ });
1775
+ offset = end;
1776
+ }
1777
+ return finalResult;
1778
+ }
1779
+
1780
+ async function registerCloudinaryUpload(file, cloudinaryResult) {
1781
+ const res = await fetch("/media/register", {
1782
+ method: "POST",
1783
+ headers: { "Content-Type": "application/json" },
1784
+ body: JSON.stringify({
1785
+ publicId: cloudinaryResult.public_id,
1786
+ resourceType: cloudinaryResult.resource_type || "auto",
1787
+ originalFilename: file.name,
1788
+ mimeType: file.type || undefined
1789
+ })
1790
+ });
1791
+ let payload = {};
1792
+ try {
1793
+ payload = await res.json();
1794
+ } catch {
1795
+ payload = {};
1796
+ }
1797
+ if (!res.ok || payload.status === "failed") {
1798
+ throw new Error(payload.error || `Register failed (${res.status})`);
1799
+ }
1800
+ return payload;
1801
+ }
1802
+
1803
+ async function uploadViaCloudinaryDirect(file, onProgress) {
1804
+ const sign = await fetchCloudinaryUploadSign(file.name);
1805
+ if (!sign) return null;
1806
+ const cloudinaryResult = await uploadToCloudinaryDirect(file, sign, onProgress);
1807
+ if (!cloudinaryResult?.public_id) {
1808
+ throw new Error("Cloudinary upload returned no public_id");
1809
+ }
1810
+ if (typeof onProgress === "function") onProgress(100);
1811
+ return registerCloudinaryUpload(file, cloudinaryResult);
1812
+ }
1813
+
1665
1814
  async function uploadFile(file, onProgress) {
1815
+ try {
1816
+ const direct = await uploadViaCloudinaryDirect(file, onProgress);
1817
+ if (direct) return direct;
1818
+ } catch (err) {
1819
+ const msg = err?.message || String(err);
1820
+ if (!/not configured|Upload sign failed \(404\)/i.test(msg)) {
1821
+ throw err;
1822
+ }
1823
+ }
1824
+
1666
1825
  let lastErr = null;
1667
1826
  for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt += 1) {
1668
1827
  try {
@@ -1670,6 +1829,8 @@ async function uploadFile(file, onProgress) {
1670
1829
  return payload;
1671
1830
  } catch (err) {
1672
1831
  lastErr = err;
1832
+ const msg = err?.message || String(err);
1833
+ if (/Upload failed \(413\)/i.test(msg)) break;
1673
1834
  if (attempt >= UPLOAD_MAX_RETRIES) break;
1674
1835
  await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
1675
1836
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-cms",
3
- "version": "0.9.51",
3
+ "version": "0.9.53",
4
4
  "description": "TomorrowOS CMS for Vercel / v0 Publish (Fluid Function + WebSockets + optional Next Preview).",
5
5
  "private": true,
6
6
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "build-player": "tomorrowos build --platform tizen"
18
18
  },
19
19
  "dependencies": {
20
- "@tomorrowos/sdk": "^0.9.51",
20
+ "@tomorrowos/sdk": "^0.9.53",
21
21
  "dotenv": "^17.2.3",
22
22
  "tsx": "^4.19.0"
23
23
  },