@tomorrowos/sdk 0.9.56 → 0.9.58
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/README.md +5 -5
- package/VERCEL_QUESTIONS.md +1 -1
- package/VERCEL_SETUP.md +2 -2
- package/dist/chunked-upload.d.ts +32 -0
- package/dist/chunked-upload.d.ts.map +1 -0
- package/dist/chunked-upload.js +134 -0
- package/dist/tomorrowos.d.ts +4 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +195 -3
- package/dist/upload-storage.d.ts +1 -0
- package/dist/upload-storage.d.ts.map +1 -1
- package/dist/upload-storage.js +4 -1
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/methods.js +107 -15
- package/templates/cms-starter-v0/cms-panel/methods.js +111 -17
- package/templates/cms-starter-v0/package.json +2 -2
package/README.md
CHANGED
|
@@ -99,8 +99,8 @@ Supported hosts: **Replit** and **Vercel / v0**.
|
|
|
99
99
|
2. On Vercel: paste Q1–Q3 **verbatim** from `VERCEL_QUESTIONS.md`; after database choice set `TOMORROWOS_STORE` and `DATABASE_SSL` yourself; after media choice set `TOMORROWOS_MEDIA` yourself.
|
|
100
100
|
3. Read `brand.schema.json` for the validated shape of `brand.json`.
|
|
101
101
|
4. Scaffold with the correct template:
|
|
102
|
-
- Replit / self-host style: `npx @tomorrowos/sdk
|
|
103
|
-
- Vercel / v0: `npx @tomorrowos/sdk
|
|
102
|
+
- Replit / self-host style: `npx @tomorrowos/sdk init` (default `cms-starter`)
|
|
103
|
+
- Vercel / v0: `npx @tomorrowos/sdk init . --hosting v0 --force` (`cms-starter-v0`)
|
|
104
104
|
5. Apply the user’s answers — brand and use case. Validate `brand.json`. On Vercel, apply the **NON-NEGOTIABLE VERCEL RUNTIME CONTRACT** from `VERCEL_SETUP.md`.
|
|
105
105
|
6. Point the user at `PLAYER_INSTALL.md` and the Control Panel **Download Players** UI once the CMS is running.
|
|
106
106
|
|
|
@@ -317,8 +317,8 @@ Works in either direction across `sqlite`, `postgres`, and `supabase`. It moves
|
|
|
317
317
|
|
|
318
318
|
### Media
|
|
319
319
|
|
|
320
|
-
- **Local / Object Storage:** `POST /media/upload
|
|
321
|
-
- **Cloudinary:** set `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET` (optional `CLOUDINARY_FOLDER`). The Control Panel uses **browser → Cloudinary** direct upload (`/media/upload-sign` + `/media/register`) when Cloudinary is configured
|
|
320
|
+
- **Local / Object Storage:** small files may use `POST /media/upload`. Larger files use **chunked upload** (`/media/upload-init` → `/media/upload-chunk` → `/media/upload-complete`) so hosts like Replit do not return HTTP 413 on ~50MB+ bodies.
|
|
321
|
+
- **Cloudinary:** set `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET` (optional `CLOUDINARY_FOLDER`). The Control Panel uses **browser → Cloudinary** direct upload (`/media/upload-sign` + `/media/register`) when Cloudinary is configured.
|
|
322
322
|
|
|
323
323
|
Cloudinary account plans still enforce their own max file sizes (for example Free plan video caps).
|
|
324
324
|
|
|
@@ -377,7 +377,7 @@ Fetch `GET /brand.json` from the panel’s network. A live WebSocket with a blac
|
|
|
377
377
|
Policy URLs must be absolute and reachable from the screen. Relative paths or hosts only visible on your laptop will fail on the panel.
|
|
378
378
|
|
|
379
379
|
**Upload fails with HTTP 413 on Replit (or similar hosts).**
|
|
380
|
-
|
|
380
|
+
Use `@tomorrowos/sdk` Control Panel / templates: non-Cloudinary uploads over ~2MB go through **chunked** `/media/upload-chunk` (works with Replit Object Storage). With Cloudinary configured, the panel uses browser direct upload instead.
|
|
381
381
|
|
|
382
382
|
**BrightSign zip has empty `cmsEndpoint`.**
|
|
383
383
|
You downloaded the mother zip from a static CDN instead of **this** CMS’s `/players/brightsign.zip`. Use Control Panel → Download Players → BrightSign on the deployed CMS.
|
package/VERCEL_QUESTIONS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vercel / v0 ? Verbatim question bank
|
|
2
2
|
|
|
3
|
-
> **Protocol:** `vercel-questions/1.1` (pairs with `VERCEL_SETUP.md` 1.10+ / `@tomorrowos/sdk` 0.9.
|
|
3
|
+
> **Protocol:** `vercel-questions/1.1` (pairs with `VERCEL_SETUP.md` 1.10+ / `@tomorrowos/sdk` 0.9.58+)
|
|
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 "Blob recommended", no "disable 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`).
|
|
7
7
|
>
|
|
8
|
-
> Protocol id: `vercel-setup/1.9` ? pairs with `@tomorrowos/sdk` **0.9.
|
|
8
|
+
> Protocol id: `vercel-setup/1.9` ? pairs with `@tomorrowos/sdk` **0.9.58+** (`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).
|
|
@@ -39,7 +39,7 @@ Open VERCEL_QUESTIONS.md first.
|
|
|
39
39
|
For Q1, Q2, Q3: paste each question block VERBATIM. Do not rephrase options.
|
|
40
40
|
After Q1 database choice, YOU set TOMORROWOS_STORE and DATABASE_SSL. Do not ask me to fill those.
|
|
41
41
|
After Q2 media storage choice, YOU set TOMORROWOS_MEDIA. Do not ask me to fill those.
|
|
42
|
-
Run: npx @tomorrowos/sdk@0.9.
|
|
42
|
+
Run: npx @tomorrowos/sdk@0.9.58 init . --hosting v0 --force
|
|
43
43
|
Keep cms-starter-v0 layout.
|
|
44
44
|
Apply the NON-NEGOTIABLE VERCEL RUNTIME CONTRACT exactly:
|
|
45
45
|
one TomorrowOS instance only
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Keep each proxy request well under typical host/body limits (Replit 413). */
|
|
2
|
+
export declare const MEDIA_UPLOAD_CHUNK_BYTES: number;
|
|
3
|
+
export interface ChunkedUploadMeta {
|
|
4
|
+
uploadId: string;
|
|
5
|
+
filename: string;
|
|
6
|
+
mimeType?: string;
|
|
7
|
+
size: number;
|
|
8
|
+
chunkSize: number;
|
|
9
|
+
totalChunks: number;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
received: number[];
|
|
12
|
+
}
|
|
13
|
+
export declare function isValidUploadId(uploadId: string): boolean;
|
|
14
|
+
export declare function chunkedUploadRoot(staticRoot: string): string;
|
|
15
|
+
export declare function chunkedUploadDir(staticRoot: string, uploadId: string): string;
|
|
16
|
+
export declare function initChunkedUpload(staticRoot: string, options: {
|
|
17
|
+
filename: string;
|
|
18
|
+
size: number;
|
|
19
|
+
mimeType?: string;
|
|
20
|
+
}): Promise<ChunkedUploadMeta>;
|
|
21
|
+
export declare function readChunkedUploadMeta(staticRoot: string, uploadId: string): Promise<ChunkedUploadMeta>;
|
|
22
|
+
export declare function storeChunkedUploadPart(staticRoot: string, uploadId: string, index: number, body: Buffer): Promise<ChunkedUploadMeta>;
|
|
23
|
+
export declare function assembleChunkedUpload(staticRoot: string, uploadId: string): Promise<{
|
|
24
|
+
filePath: string;
|
|
25
|
+
sha256: string;
|
|
26
|
+
bytes: number;
|
|
27
|
+
meta: ChunkedUploadMeta;
|
|
28
|
+
}>;
|
|
29
|
+
export declare function cleanupChunkedUpload(staticRoot: string, uploadId: string): Promise<void>;
|
|
30
|
+
/** Best-effort: drop unfinished uploads older than maxAgeMs. */
|
|
31
|
+
export declare function cleanupStaleChunkedUploads(staticRoot: string, maxAgeMs?: number): Promise<void>;
|
|
32
|
+
//# sourceMappingURL=chunked-upload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunked-upload.d.ts","sourceRoot":"","sources":["../src/chunked-upload.ts"],"names":[],"mappings":"AAIA,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,QAAkB,CAAC;AAIxD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAK7E;AAED,wBAAsB,iBAAiB,CACrC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAAC,iBAAiB,CAAC,CA2B5B;AAED,wBAAsB,qBAAqB,CACzC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,iBAAiB,CAAC,CAI5B;AAUD,wBAAsB,sBAAsB,CAC1C,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CA0B5B;AAED,wBAAsB,qBAAqB,CACzC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,iBAAiB,CAAA;CAAE,CAAC,CAiCvF;AAED,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAIf;AAED,gEAAgE;AAChE,wBAAsB,0BAA0B,CAC9C,UAAU,EAAE,MAAM,EAClB,QAAQ,SAAqB,GAC5B,OAAO,CAAC,IAAI,CAAC,CAuBf"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "crypto";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
/** Keep each proxy request well under typical host/body limits (Replit 413). */
|
|
5
|
+
export const MEDIA_UPLOAD_CHUNK_BYTES = 1 * 1024 * 1024;
|
|
6
|
+
const UPLOAD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
export function isValidUploadId(uploadId) {
|
|
8
|
+
return UPLOAD_ID_RE.test(String(uploadId || "").trim());
|
|
9
|
+
}
|
|
10
|
+
export function chunkedUploadRoot(staticRoot) {
|
|
11
|
+
return path.join(path.resolve(staticRoot), ".upload-tmp");
|
|
12
|
+
}
|
|
13
|
+
export function chunkedUploadDir(staticRoot, uploadId) {
|
|
14
|
+
if (!isValidUploadId(uploadId)) {
|
|
15
|
+
throw new Error("Invalid uploadId");
|
|
16
|
+
}
|
|
17
|
+
return path.join(chunkedUploadRoot(staticRoot), uploadId);
|
|
18
|
+
}
|
|
19
|
+
export async function initChunkedUpload(staticRoot, options) {
|
|
20
|
+
const size = Number(options.size);
|
|
21
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
22
|
+
throw new Error("size must be a positive number");
|
|
23
|
+
}
|
|
24
|
+
if (size > 20 * 1024 * 1024 * 1024) {
|
|
25
|
+
throw new Error("File too large (max 20GB)");
|
|
26
|
+
}
|
|
27
|
+
const uploadId = randomUUID();
|
|
28
|
+
const chunkSize = MEDIA_UPLOAD_CHUNK_BYTES;
|
|
29
|
+
const totalChunks = Math.ceil(size / chunkSize);
|
|
30
|
+
const meta = {
|
|
31
|
+
uploadId,
|
|
32
|
+
filename: options.filename,
|
|
33
|
+
mimeType: options.mimeType,
|
|
34
|
+
size,
|
|
35
|
+
chunkSize,
|
|
36
|
+
totalChunks,
|
|
37
|
+
createdAt: new Date().toISOString(),
|
|
38
|
+
received: []
|
|
39
|
+
};
|
|
40
|
+
const dir = chunkedUploadDir(staticRoot, uploadId);
|
|
41
|
+
await fs.mkdir(dir, { recursive: true });
|
|
42
|
+
await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(meta), "utf8");
|
|
43
|
+
return meta;
|
|
44
|
+
}
|
|
45
|
+
export async function readChunkedUploadMeta(staticRoot, uploadId) {
|
|
46
|
+
const dir = chunkedUploadDir(staticRoot, uploadId);
|
|
47
|
+
const raw = await fs.readFile(path.join(dir, "meta.json"), "utf8");
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
async function writeChunkedUploadMeta(staticRoot, meta) {
|
|
51
|
+
const dir = chunkedUploadDir(staticRoot, meta.uploadId);
|
|
52
|
+
await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(meta), "utf8");
|
|
53
|
+
}
|
|
54
|
+
export async function storeChunkedUploadPart(staticRoot, uploadId, index, body) {
|
|
55
|
+
const meta = await readChunkedUploadMeta(staticRoot, uploadId);
|
|
56
|
+
if (!Number.isInteger(index) || index < 0 || index >= meta.totalChunks) {
|
|
57
|
+
throw new Error(`Invalid chunk index ${index}`);
|
|
58
|
+
}
|
|
59
|
+
const expected = index === meta.totalChunks - 1
|
|
60
|
+
? meta.size - meta.chunkSize * (meta.totalChunks - 1)
|
|
61
|
+
: meta.chunkSize;
|
|
62
|
+
if (body.length !== expected) {
|
|
63
|
+
throw new Error(`Chunk ${index} size mismatch (got ${body.length}, expected ${expected})`);
|
|
64
|
+
}
|
|
65
|
+
const dir = chunkedUploadDir(staticRoot, uploadId);
|
|
66
|
+
const partPath = path.join(dir, `part-${String(index).padStart(6, "0")}`);
|
|
67
|
+
await fs.writeFile(partPath, body);
|
|
68
|
+
if (!meta.received.includes(index)) {
|
|
69
|
+
meta.received.push(index);
|
|
70
|
+
meta.received.sort((a, b) => a - b);
|
|
71
|
+
await writeChunkedUploadMeta(staticRoot, meta);
|
|
72
|
+
}
|
|
73
|
+
return meta;
|
|
74
|
+
}
|
|
75
|
+
export async function assembleChunkedUpload(staticRoot, uploadId) {
|
|
76
|
+
const meta = await readChunkedUploadMeta(staticRoot, uploadId);
|
|
77
|
+
if (meta.received.length !== meta.totalChunks) {
|
|
78
|
+
throw new Error(`Upload incomplete (${meta.received.length}/${meta.totalChunks} chunks)`);
|
|
79
|
+
}
|
|
80
|
+
const dir = chunkedUploadDir(staticRoot, uploadId);
|
|
81
|
+
const assembledPath = path.join(dir, "assembled.bin");
|
|
82
|
+
const hash = createHash("sha256");
|
|
83
|
+
await fs.writeFile(assembledPath, Buffer.alloc(0));
|
|
84
|
+
for (let i = 0; i < meta.totalChunks; i += 1) {
|
|
85
|
+
const partPath = path.join(dir, `part-${String(i).padStart(6, "0")}`);
|
|
86
|
+
const data = await fs.readFile(partPath);
|
|
87
|
+
hash.update(data);
|
|
88
|
+
await fs.appendFile(assembledPath, data);
|
|
89
|
+
}
|
|
90
|
+
const stat = await fs.stat(assembledPath);
|
|
91
|
+
if (stat.size !== meta.size) {
|
|
92
|
+
throw new Error(`Assembled size mismatch (got ${stat.size}, expected ${meta.size})`);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
filePath: assembledPath,
|
|
96
|
+
sha256: hash.digest("hex"),
|
|
97
|
+
bytes: stat.size,
|
|
98
|
+
meta
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function cleanupChunkedUpload(staticRoot, uploadId) {
|
|
102
|
+
if (!isValidUploadId(uploadId))
|
|
103
|
+
return;
|
|
104
|
+
const dir = chunkedUploadDir(staticRoot, uploadId);
|
|
105
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
/** Best-effort: drop unfinished uploads older than maxAgeMs. */
|
|
108
|
+
export async function cleanupStaleChunkedUploads(staticRoot, maxAgeMs = 6 * 60 * 60 * 1000) {
|
|
109
|
+
const root = chunkedUploadRoot(staticRoot);
|
|
110
|
+
let entries = [];
|
|
111
|
+
try {
|
|
112
|
+
entries = await fs.readdir(root);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
for (const name of entries) {
|
|
119
|
+
if (!isValidUploadId(name))
|
|
120
|
+
continue;
|
|
121
|
+
const metaPath = path.join(root, name, "meta.json");
|
|
122
|
+
try {
|
|
123
|
+
const raw = await fs.readFile(metaPath, "utf8");
|
|
124
|
+
const meta = JSON.parse(raw);
|
|
125
|
+
const created = Date.parse(meta.createdAt);
|
|
126
|
+
if (!Number.isFinite(created) || now - created > maxAgeMs) {
|
|
127
|
+
await fs.rm(path.join(root, name), { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
await fs.rm(path.join(root, name), { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -209,6 +209,10 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
209
209
|
private handleMediaUpload;
|
|
210
210
|
private handleMediaUploadSign;
|
|
211
211
|
private handleMediaRegister;
|
|
212
|
+
private handleMediaUploadInit;
|
|
213
|
+
private handleMediaUploadChunk;
|
|
214
|
+
private handleMediaUploadComplete;
|
|
215
|
+
private storeLocalAssembledUpload;
|
|
212
216
|
private storeUploadedMediaAsset;
|
|
213
217
|
private getAssetIdsFromItems;
|
|
214
218
|
private isUploadedAssetReferenced;
|
package/dist/tomorrowos.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -3,6 +3,7 @@ import { EventEmitter } from "events";
|
|
|
3
3
|
import fs from "fs/promises";
|
|
4
4
|
import http from "http";
|
|
5
5
|
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
6
7
|
import { WebSocket, WebSocketServer } from "ws";
|
|
7
8
|
import { generateDeterministicPairingCode, generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCode, resolvePairingCodeSecret } from "./pairing-code.js";
|
|
8
9
|
import { PlaylistCatalog } from "./playlist-catalog.js";
|
|
@@ -10,13 +11,22 @@ import { MemoryStore } from "./store/memory-store.js";
|
|
|
10
11
|
import { resolveBrandLogoPath, syncProjectAssetsToStaticRoot } from "./brand-assets.js";
|
|
11
12
|
import { createSignedUploadParams, deleteCloudinaryAsset, fetchCloudinaryResource, resolveCloudinaryConfig, uploadBufferToCloudinary } from "./cloudinary-storage.js";
|
|
12
13
|
import { probeVideoDurationMs } from "./media-probe.js";
|
|
14
|
+
import { assembleChunkedUpload, cleanupChunkedUpload, cleanupStaleChunkedUploads, initChunkedUpload, MEDIA_UPLOAD_CHUNK_BYTES, storeChunkedUploadPart } from "./chunked-upload.js";
|
|
13
15
|
import { buildBrightSignZipWithCmsEndpoint, resolveRequestCmsOrigin } from "./player-zip.js";
|
|
14
|
-
import { contentHashHex, storeUploadIfNeeded } from "./upload-storage.js";
|
|
16
|
+
import { buildContentAddressedNameFromHash, contentHashHex, storeUploadIfNeeded } from "./upload-storage.js";
|
|
15
17
|
import { detectPrimaryLanIpv4 } from "./lan-address.js";
|
|
16
18
|
import { buildServerStatus } from "./server-status.js";
|
|
17
19
|
/** Mark offline if no device.ping within this window (power-loss / half-open TCP). */
|
|
18
20
|
const DEVICE_HEARTBEAT_TIMEOUT_MS = 30_000;
|
|
19
21
|
const DEVICE_HEARTBEAT_SWEEP_MS = 10_000;
|
|
22
|
+
function sdkPackageRoot() {
|
|
23
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
}
|
|
25
|
+
/** Project methods.js without chunked upload still hits host 413 on large files. */
|
|
26
|
+
function methodsJsSupportsChunkedUpload(source) {
|
|
27
|
+
return (source.includes("uploadViaChunkedProxy") &&
|
|
28
|
+
source.includes("/media/upload-init"));
|
|
29
|
+
}
|
|
20
30
|
function normalizeDevicePlatform(platform, system) {
|
|
21
31
|
const explicit = typeof platform === "string" ? platform.trim().toLowerCase() : "";
|
|
22
32
|
if (explicit)
|
|
@@ -886,6 +896,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
886
896
|
this.staticIndexFile = idx;
|
|
887
897
|
const uploadsDir = path.join(path.resolve(this.staticRoot), "uploads");
|
|
888
898
|
void fs.mkdir(uploadsDir, { recursive: true });
|
|
899
|
+
void cleanupStaleChunkedUploads(this.staticRoot).catch(() => undefined);
|
|
889
900
|
void this.refreshResolvedBrandLogo();
|
|
890
901
|
}
|
|
891
902
|
else {
|
|
@@ -940,6 +951,18 @@ export class TomorrowOS extends EventEmitter {
|
|
|
940
951
|
return;
|
|
941
952
|
}
|
|
942
953
|
try {
|
|
954
|
+
const declaredLength = Number(req.headers["content-length"]);
|
|
955
|
+
if (Number.isFinite(declaredLength) &&
|
|
956
|
+
declaredLength > MEDIA_UPLOAD_CHUNK_BYTES) {
|
|
957
|
+
// Avoid buffering a huge body that many hosts (e.g. Replit) reject with 413.
|
|
958
|
+
req.resume();
|
|
959
|
+
sendJson(res, 413, {
|
|
960
|
+
status: "failed",
|
|
961
|
+
code: "USE_CHUNKED",
|
|
962
|
+
error: "File too large for single-request upload. Soft-refresh the Control Panel (SDK serves chunked methods.js) or copy templates/.../methods.js, then retry."
|
|
963
|
+
});
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
943
966
|
const body = await readRawBody(req, MAX_MEDIA_UPLOAD_BYTES);
|
|
944
967
|
if (body.length === 0) {
|
|
945
968
|
sendJson(res, 400, { status: "failed", error: "Empty upload body" });
|
|
@@ -1065,6 +1088,141 @@ export class TomorrowOS extends EventEmitter {
|
|
|
1065
1088
|
sendJson(res, 400, { status: "failed", error: msg });
|
|
1066
1089
|
}
|
|
1067
1090
|
}
|
|
1091
|
+
async handleMediaUploadInit(req, res) {
|
|
1092
|
+
if (!this.staticRoot) {
|
|
1093
|
+
sendJson(res, 400, { status: "failed", error: "staticRoot is not configured" });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
const body = (await readJsonBody(req));
|
|
1098
|
+
const safeName = sanitizeUploadFilename(body.filename || "upload");
|
|
1099
|
+
const mimeType = typeof body.mimeType === "string" ? body.mimeType : undefined;
|
|
1100
|
+
const meta = await initChunkedUpload(this.staticRoot, {
|
|
1101
|
+
filename: safeName,
|
|
1102
|
+
size: Number(body.size),
|
|
1103
|
+
mimeType
|
|
1104
|
+
});
|
|
1105
|
+
sendJson(res, 200, {
|
|
1106
|
+
status: "success",
|
|
1107
|
+
mode: "chunked",
|
|
1108
|
+
uploadId: meta.uploadId,
|
|
1109
|
+
chunkSize: meta.chunkSize,
|
|
1110
|
+
totalChunks: meta.totalChunks
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
catch (e) {
|
|
1114
|
+
const msg = e instanceof Error ? e.message : "Upload init failed";
|
|
1115
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
async handleMediaUploadChunk(req, res, url) {
|
|
1119
|
+
if (!this.staticRoot) {
|
|
1120
|
+
sendJson(res, 400, { status: "failed", error: "staticRoot is not configured" });
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
const uploadId = String(url.searchParams.get("uploadId") || "").trim();
|
|
1125
|
+
const index = Number(url.searchParams.get("index"));
|
|
1126
|
+
const body = await readRawBody(req, MEDIA_UPLOAD_CHUNK_BYTES + 64 * 1024);
|
|
1127
|
+
if (body.length === 0) {
|
|
1128
|
+
sendJson(res, 400, { status: "failed", error: "Empty chunk body" });
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const meta = await storeChunkedUploadPart(this.staticRoot, uploadId, index, body);
|
|
1132
|
+
sendJson(res, 200, {
|
|
1133
|
+
status: "success",
|
|
1134
|
+
uploadId: meta.uploadId,
|
|
1135
|
+
index,
|
|
1136
|
+
received: meta.received.length,
|
|
1137
|
+
totalChunks: meta.totalChunks
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
catch (e) {
|
|
1141
|
+
const msg = e instanceof Error ? e.message : "Chunk upload failed";
|
|
1142
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
async handleMediaUploadComplete(req, res) {
|
|
1146
|
+
if (!this.staticRoot) {
|
|
1147
|
+
sendJson(res, 400, { status: "failed", error: "staticRoot is not configured" });
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
let uploadId = "";
|
|
1151
|
+
try {
|
|
1152
|
+
const body = (await readJsonBody(req));
|
|
1153
|
+
uploadId = String(body.uploadId || "").trim();
|
|
1154
|
+
const assembled = await assembleChunkedUpload(this.staticRoot, uploadId);
|
|
1155
|
+
const safeName = sanitizeUploadFilename(assembled.meta.filename);
|
|
1156
|
+
const mimeType = assembled.meta.mimeType;
|
|
1157
|
+
const { asset, deduplicated } = await this.storeLocalAssembledUpload(assembled.filePath, assembled.sha256, assembled.bytes, safeName, mimeType);
|
|
1158
|
+
let durationMs = null;
|
|
1159
|
+
if (assembled.bytes <= 64 * 1024 * 1024) {
|
|
1160
|
+
try {
|
|
1161
|
+
const probeBuf = await fs.readFile(assembled.filePath);
|
|
1162
|
+
durationMs = probeVideoDurationMs(probeBuf, safeName);
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
durationMs = null;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
const payload = {
|
|
1169
|
+
status: "success",
|
|
1170
|
+
url: asset.url,
|
|
1171
|
+
assetId: asset.id,
|
|
1172
|
+
filename: isRemoteUploadedAssetUrl(asset.url) ? safeName : asset.storageKey,
|
|
1173
|
+
size: asset.bytes ?? assembled.bytes,
|
|
1174
|
+
contentHash: asset.sha256,
|
|
1175
|
+
deduplicated
|
|
1176
|
+
};
|
|
1177
|
+
if (durationMs != null)
|
|
1178
|
+
payload.durationMs = durationMs;
|
|
1179
|
+
sendJson(res, 200, payload);
|
|
1180
|
+
}
|
|
1181
|
+
catch (e) {
|
|
1182
|
+
const msg = e instanceof Error ? e.message : "Upload complete failed";
|
|
1183
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
1184
|
+
}
|
|
1185
|
+
finally {
|
|
1186
|
+
if (uploadId && this.staticRoot) {
|
|
1187
|
+
void cleanupChunkedUpload(this.staticRoot, uploadId).catch(() => undefined);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
async storeLocalAssembledUpload(assembledPath, sha256, bytes, safeName, mimeType) {
|
|
1192
|
+
const existing = await this.store.getUploadedAssetBySha256(sha256);
|
|
1193
|
+
if (existing && !isRemoteUploadedAssetUrl(existing.url)) {
|
|
1194
|
+
const existingLocalPath = path.join(path.resolve(this.staticRoot || "."), "uploads", existing.storageKey);
|
|
1195
|
+
try {
|
|
1196
|
+
const stat = await fs.stat(existingLocalPath);
|
|
1197
|
+
if (stat.size === bytes) {
|
|
1198
|
+
return { asset: existing, deduplicated: true };
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
// rewrite below
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const uploadsDir = path.join(path.resolve(this.staticRoot || "."), "uploads");
|
|
1206
|
+
await fs.mkdir(uploadsDir, { recursive: true });
|
|
1207
|
+
const storedName = buildContentAddressedNameFromHash(sha256, safeName);
|
|
1208
|
+
const destPath = path.join(uploadsDir, storedName);
|
|
1209
|
+
await fs.copyFile(assembledPath, destPath);
|
|
1210
|
+
const now = new Date().toISOString();
|
|
1211
|
+
const asset = {
|
|
1212
|
+
id: existing?.id ?? randomUUID(),
|
|
1213
|
+
sha256,
|
|
1214
|
+
storageKey: storedName,
|
|
1215
|
+
url: `/uploads/${storedName}`,
|
|
1216
|
+
originalFilename: safeName,
|
|
1217
|
+
mimeType,
|
|
1218
|
+
resourceType: inferResourceType(mimeType, safeName),
|
|
1219
|
+
bytes,
|
|
1220
|
+
createdAt: existing?.createdAt ?? now,
|
|
1221
|
+
updatedAt: now
|
|
1222
|
+
};
|
|
1223
|
+
await this.store.setUploadedAsset(asset);
|
|
1224
|
+
return { asset, deduplicated: false };
|
|
1225
|
+
}
|
|
1068
1226
|
async storeUploadedMediaAsset(body, safeName, mimeType) {
|
|
1069
1227
|
const sha256 = contentHashHex(body);
|
|
1070
1228
|
const cloudinaryConfig = resolveCloudinaryConfig();
|
|
@@ -1213,8 +1371,26 @@ export class TomorrowOS extends EventEmitter {
|
|
|
1213
1371
|
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
|
|
1214
1372
|
return false;
|
|
1215
1373
|
}
|
|
1374
|
+
const relPosix = relativeToRoot.split(path.sep).join("/");
|
|
1375
|
+
if (relPosix === ".upload-tmp" || relPosix.startsWith(".upload-tmp/")) {
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1216
1378
|
try {
|
|
1217
|
-
|
|
1379
|
+
let buf = await fs.readFile(filePath);
|
|
1380
|
+
const baseName = path.basename(rel).toLowerCase();
|
|
1381
|
+
if (baseName === "methods.js") {
|
|
1382
|
+
const text = buf.toString("utf8");
|
|
1383
|
+
if (!methodsJsSupportsChunkedUpload(text)) {
|
|
1384
|
+
const fallback = path.join(sdkPackageRoot(), "templates", "cms-starter", "public", "methods.js");
|
|
1385
|
+
try {
|
|
1386
|
+
buf = await fs.readFile(fallback);
|
|
1387
|
+
console.warn("[TomorrowOS] serving SDK methods.js with chunked uploads (project methods.js is outdated)");
|
|
1388
|
+
}
|
|
1389
|
+
catch {
|
|
1390
|
+
// keep project file
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1218
1394
|
const ext = path.extname(rel).toLowerCase();
|
|
1219
1395
|
const types = {
|
|
1220
1396
|
".html": "text/html; charset=utf-8",
|
|
@@ -1229,7 +1405,11 @@ export class TomorrowOS extends EventEmitter {
|
|
|
1229
1405
|
".gif": "image/gif"
|
|
1230
1406
|
};
|
|
1231
1407
|
const ctype = types[ext] ?? "application/octet-stream";
|
|
1232
|
-
|
|
1408
|
+
const headers = { "Content-Type": ctype };
|
|
1409
|
+
if (baseName === "methods.js") {
|
|
1410
|
+
headers["Cache-Control"] = "no-store";
|
|
1411
|
+
}
|
|
1412
|
+
res.writeHead(200, headers);
|
|
1233
1413
|
res.end(buf);
|
|
1234
1414
|
return true;
|
|
1235
1415
|
}
|
|
@@ -1373,6 +1553,18 @@ export class TomorrowOS extends EventEmitter {
|
|
|
1373
1553
|
await this.handleMediaRegister(req, res);
|
|
1374
1554
|
return;
|
|
1375
1555
|
}
|
|
1556
|
+
if (req.method === "POST" && pathname === "/media/upload-init") {
|
|
1557
|
+
await this.handleMediaUploadInit(req, res);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (req.method === "POST" && pathname === "/media/upload-chunk") {
|
|
1561
|
+
await this.handleMediaUploadChunk(req, res, url);
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (req.method === "POST" && pathname === "/media/upload-complete") {
|
|
1565
|
+
await this.handleMediaUploadComplete(req, res);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1376
1568
|
if (req.method === "GET" && pathname === "/players/brightsign.zip") {
|
|
1377
1569
|
try {
|
|
1378
1570
|
const cmsOrigin = resolveRequestCmsOrigin(req);
|
package/dist/upload-storage.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Stable on-disk name: first 16 chars of SHA-256 + original safe filename. */
|
|
2
2
|
export declare function buildContentAddressedName(body: Buffer, safeName: string): string;
|
|
3
|
+
export declare function buildContentAddressedNameFromHash(sha256Hex: string, safeName: string): string;
|
|
3
4
|
export declare function contentHashHex(body: Buffer): string;
|
|
4
5
|
/**
|
|
5
6
|
* Write upload once per unique file bytes; reuse existing file when present.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload-storage.d.ts","sourceRoot":"","sources":["../src/upload-storage.ts"],"names":[],"mappings":"AAIA,+EAA+E;AAC/E,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGhF;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAgB7E"}
|
|
1
|
+
{"version":3,"file":"upload-storage.d.ts","sourceRoot":"","sources":["../src/upload-storage.ts"],"names":[],"mappings":"AAIA,+EAA+E;AAC/E,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGhF;AAED,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAER;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAgB7E"}
|
package/dist/upload-storage.js
CHANGED
|
@@ -4,7 +4,10 @@ import path from "path";
|
|
|
4
4
|
/** Stable on-disk name: first 16 chars of SHA-256 + original safe filename. */
|
|
5
5
|
export function buildContentAddressedName(body, safeName) {
|
|
6
6
|
const hash = createHash("sha256").update(body).digest("hex");
|
|
7
|
-
return
|
|
7
|
+
return buildContentAddressedNameFromHash(hash, safeName);
|
|
8
|
+
}
|
|
9
|
+
export function buildContentAddressedNameFromHash(sha256Hex, safeName) {
|
|
10
|
+
return `${String(sha256Hex).slice(0, 16)}-${safeName}`;
|
|
8
11
|
}
|
|
9
12
|
export function contentHashHex(body) {
|
|
10
13
|
return createHash("sha256").update(body).digest("hex");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.58",
|
|
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.
|
|
3
|
+
"version": "0.9.58",
|
|
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.
|
|
16
|
+
"@tomorrowos/sdk": "^0.9.58",
|
|
17
17
|
"dotenv": "^17.2.3",
|
|
18
18
|
"tsx": "^4.19.0"
|
|
19
19
|
},
|
|
@@ -47,6 +47,9 @@ const UPLOAD_TIMEOUT_MS = 120000;
|
|
|
47
47
|
const CLOUDINARY_DIRECT_TIMEOUT_MS = 0;
|
|
48
48
|
/** Cloudinary chunked upload minimum is 5MB; use 8MB chunks. */
|
|
49
49
|
const CLOUDINARY_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
50
|
+
/** Single-request proxy uploads stay small; larger files use /media/upload-chunk. */
|
|
51
|
+
const PROXY_SINGLE_MAX_BYTES = 2 * 1024 * 1024;
|
|
52
|
+
const CHUNKED_PROXY_TIMEOUT_MS = 0;
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
55
|
* Apply brand.json to the Control Panel (name + colours). No logo on the panel.
|
|
@@ -1811,6 +1814,108 @@ async function uploadViaCloudinaryDirect(file, onProgress) {
|
|
|
1811
1814
|
return registerCloudinaryUpload(file, cloudinaryResult);
|
|
1812
1815
|
}
|
|
1813
1816
|
|
|
1817
|
+
function xhrSendBinary(url, body, { timeoutMs, onProgress, contentType }) {
|
|
1818
|
+
return new Promise((resolve, reject) => {
|
|
1819
|
+
const xhr = new XMLHttpRequest();
|
|
1820
|
+
xhr.open("POST", url, true);
|
|
1821
|
+
xhr.timeout = timeoutMs;
|
|
1822
|
+
if (contentType) xhr.setRequestHeader("Content-Type", contentType);
|
|
1823
|
+
xhr.upload.onprogress = (event) => {
|
|
1824
|
+
if (!event.lengthComputable) return;
|
|
1825
|
+
if (typeof onProgress === "function") onProgress(event.loaded, event.total);
|
|
1826
|
+
};
|
|
1827
|
+
xhr.onerror = () => reject(new Error("Network error on chunked upload"));
|
|
1828
|
+
xhr.ontimeout = () => reject(new Error("Chunked upload timed out"));
|
|
1829
|
+
xhr.onload = () => {
|
|
1830
|
+
let payload = {};
|
|
1831
|
+
try {
|
|
1832
|
+
payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
|
|
1833
|
+
} catch {
|
|
1834
|
+
payload = {};
|
|
1835
|
+
}
|
|
1836
|
+
if (xhr.status >= 200 && xhr.status < 300 && payload.status !== "failed") {
|
|
1837
|
+
resolve(payload);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
reject(new Error(payload.error || `Upload failed (${xhr.status})`));
|
|
1841
|
+
};
|
|
1842
|
+
xhr.send(body);
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
async function uploadViaChunkedProxy(file, onProgress) {
|
|
1847
|
+
const initRes = await fetch("/media/upload-init", {
|
|
1848
|
+
method: "POST",
|
|
1849
|
+
headers: { "Content-Type": "application/json" },
|
|
1850
|
+
body: JSON.stringify({
|
|
1851
|
+
filename: file.name,
|
|
1852
|
+
size: file.size,
|
|
1853
|
+
mimeType: file.type || undefined
|
|
1854
|
+
})
|
|
1855
|
+
});
|
|
1856
|
+
let initPayload = {};
|
|
1857
|
+
try {
|
|
1858
|
+
initPayload = await initRes.json();
|
|
1859
|
+
} catch {
|
|
1860
|
+
initPayload = {};
|
|
1861
|
+
}
|
|
1862
|
+
if (!initRes.ok || initPayload.status === "failed" || !initPayload.uploadId) {
|
|
1863
|
+
throw new Error(initPayload.error || `Upload init failed (${initRes.status})`);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const uploadId = String(initPayload.uploadId);
|
|
1867
|
+
const chunkSize = Number(initPayload.chunkSize) || 4 * 1024 * 1024;
|
|
1868
|
+
const totalChunks =
|
|
1869
|
+
Number(initPayload.totalChunks) || Math.ceil(file.size / chunkSize);
|
|
1870
|
+
let uploadedBytes = 0;
|
|
1871
|
+
|
|
1872
|
+
for (let index = 0; index < totalChunks; index += 1) {
|
|
1873
|
+
const start = index * chunkSize;
|
|
1874
|
+
const end = Math.min(start + chunkSize, file.size);
|
|
1875
|
+
const chunk = file.slice(start, end);
|
|
1876
|
+
const q = new URLSearchParams({
|
|
1877
|
+
uploadId,
|
|
1878
|
+
index: String(index)
|
|
1879
|
+
});
|
|
1880
|
+
await xhrSendBinary(`/media/upload-chunk?${q.toString()}`, chunk, {
|
|
1881
|
+
timeoutMs: CHUNKED_PROXY_TIMEOUT_MS,
|
|
1882
|
+
contentType: "application/octet-stream",
|
|
1883
|
+
onProgress: (loaded) => {
|
|
1884
|
+
if (typeof onProgress !== "function") return;
|
|
1885
|
+
const overall = Math.min(file.size, uploadedBytes + loaded);
|
|
1886
|
+
onProgress(Math.round((overall / file.size) * 100));
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
uploadedBytes = end;
|
|
1890
|
+
if (typeof onProgress === "function") {
|
|
1891
|
+
onProgress(Math.round((uploadedBytes / file.size) * 100));
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
const completeRes = await fetch("/media/upload-complete", {
|
|
1896
|
+
method: "POST",
|
|
1897
|
+
headers: { "Content-Type": "application/json" },
|
|
1898
|
+
body: JSON.stringify({ uploadId })
|
|
1899
|
+
});
|
|
1900
|
+
let completePayload = {};
|
|
1901
|
+
try {
|
|
1902
|
+
completePayload = await completeRes.json();
|
|
1903
|
+
} catch {
|
|
1904
|
+
completePayload = {};
|
|
1905
|
+
}
|
|
1906
|
+
if (
|
|
1907
|
+
!completeRes.ok ||
|
|
1908
|
+
completePayload.status === "failed" ||
|
|
1909
|
+
!completePayload.assetId
|
|
1910
|
+
) {
|
|
1911
|
+
throw new Error(
|
|
1912
|
+
completePayload.error || `Upload complete failed (${completeRes.status})`
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
if (typeof onProgress === "function") onProgress(100);
|
|
1916
|
+
return completePayload;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1814
1919
|
async function uploadFile(file, onProgress) {
|
|
1815
1920
|
try {
|
|
1816
1921
|
const direct = await uploadViaCloudinaryDirect(file, onProgress);
|
|
@@ -1823,21 +1928,8 @@ async function uploadFile(file, onProgress) {
|
|
|
1823
1928
|
}
|
|
1824
1929
|
}
|
|
1825
1930
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
try {
|
|
1829
|
-
const payload = await uploadFileWithProgress(file, { onProgress, attempt });
|
|
1830
|
-
return payload;
|
|
1831
|
-
} catch (err) {
|
|
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;
|
|
1836
|
-
if (attempt >= UPLOAD_MAX_RETRIES) break;
|
|
1837
|
-
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
throw lastErr || new Error("Upload failed");
|
|
1931
|
+
// Always chunk through the CMS for Object Storage / local — avoids Replit 413.
|
|
1932
|
+
return uploadViaChunkedProxy(file, onProgress);
|
|
1841
1933
|
}
|
|
1842
1934
|
|
|
1843
1935
|
async function addAssetFromFile(file, queueIndex, queueTotal) {
|
|
@@ -47,6 +47,9 @@ const UPLOAD_TIMEOUT_MS = 120000;
|
|
|
47
47
|
const CLOUDINARY_DIRECT_TIMEOUT_MS = 0;
|
|
48
48
|
/** Cloudinary chunked upload minimum is 5MB; use 8MB chunks. */
|
|
49
49
|
const CLOUDINARY_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
50
|
+
/** Single-request proxy uploads stay small; larger files use /media/upload-chunk. */
|
|
51
|
+
const PROXY_SINGLE_MAX_BYTES = 2 * 1024 * 1024;
|
|
52
|
+
const CHUNKED_PROXY_TIMEOUT_MS = 0;
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
55
|
* Apply brand.json to the Control Panel (name + colours). No logo on the panel.
|
|
@@ -60,10 +63,9 @@ async function applyBrandFromServer() {
|
|
|
60
63
|
if (!brand || typeof brand !== "object") return;
|
|
61
64
|
|
|
62
65
|
const name = String(brand.name || "").trim() || "TomorrowOS";
|
|
63
|
-
|
|
64
|
-
document.title = panelTitle;
|
|
66
|
+
document.title = name;
|
|
65
67
|
const heading = document.querySelector(".app-header h1");
|
|
66
|
-
if (heading) heading.textContent =
|
|
68
|
+
if (heading) heading.textContent = name;
|
|
67
69
|
|
|
68
70
|
const tagline = String(brand.tagline || "").trim();
|
|
69
71
|
const subtitle = document.querySelector(".app-header p");
|
|
@@ -1760,6 +1762,7 @@ async function uploadToCloudinaryDirect(file, sign, onProgress) {
|
|
|
1760
1762
|
const chunk = file.slice(offset, end);
|
|
1761
1763
|
const form = buildCloudinaryFormData(chunk, sign);
|
|
1762
1764
|
const chunkBase = offset;
|
|
1765
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1763
1766
|
finalResult = await xhrSendForm(uploadUrl, form, {
|
|
1764
1767
|
timeoutMs: CLOUDINARY_DIRECT_TIMEOUT_MS,
|
|
1765
1768
|
headers: {
|
|
@@ -1811,31 +1814,122 @@ async function uploadViaCloudinaryDirect(file, onProgress) {
|
|
|
1811
1814
|
return registerCloudinaryUpload(file, cloudinaryResult);
|
|
1812
1815
|
}
|
|
1813
1816
|
|
|
1817
|
+
function xhrSendBinary(url, body, { timeoutMs, onProgress, contentType }) {
|
|
1818
|
+
return new Promise((resolve, reject) => {
|
|
1819
|
+
const xhr = new XMLHttpRequest();
|
|
1820
|
+
xhr.open("POST", url, true);
|
|
1821
|
+
xhr.timeout = timeoutMs;
|
|
1822
|
+
if (contentType) xhr.setRequestHeader("Content-Type", contentType);
|
|
1823
|
+
xhr.upload.onprogress = (event) => {
|
|
1824
|
+
if (!event.lengthComputable) return;
|
|
1825
|
+
if (typeof onProgress === "function") onProgress(event.loaded, event.total);
|
|
1826
|
+
};
|
|
1827
|
+
xhr.onerror = () => reject(new Error("Network error on chunked upload"));
|
|
1828
|
+
xhr.ontimeout = () => reject(new Error("Chunked upload timed out"));
|
|
1829
|
+
xhr.onload = () => {
|
|
1830
|
+
let payload = {};
|
|
1831
|
+
try {
|
|
1832
|
+
payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
|
|
1833
|
+
} catch {
|
|
1834
|
+
payload = {};
|
|
1835
|
+
}
|
|
1836
|
+
if (xhr.status >= 200 && xhr.status < 300 && payload.status !== "failed") {
|
|
1837
|
+
resolve(payload);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
reject(new Error(payload.error || `Upload failed (${xhr.status})`));
|
|
1841
|
+
};
|
|
1842
|
+
xhr.send(body);
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
async function uploadViaChunkedProxy(file, onProgress) {
|
|
1847
|
+
const initRes = await fetch("/media/upload-init", {
|
|
1848
|
+
method: "POST",
|
|
1849
|
+
headers: { "Content-Type": "application/json" },
|
|
1850
|
+
body: JSON.stringify({
|
|
1851
|
+
filename: file.name,
|
|
1852
|
+
size: file.size,
|
|
1853
|
+
mimeType: file.type || undefined
|
|
1854
|
+
})
|
|
1855
|
+
});
|
|
1856
|
+
let initPayload = {};
|
|
1857
|
+
try {
|
|
1858
|
+
initPayload = await initRes.json();
|
|
1859
|
+
} catch {
|
|
1860
|
+
initPayload = {};
|
|
1861
|
+
}
|
|
1862
|
+
if (!initRes.ok || initPayload.status === "failed" || !initPayload.uploadId) {
|
|
1863
|
+
throw new Error(initPayload.error || `Upload init failed (${initRes.status})`);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const uploadId = String(initPayload.uploadId);
|
|
1867
|
+
const chunkSize = Number(initPayload.chunkSize) || 4 * 1024 * 1024;
|
|
1868
|
+
const totalChunks =
|
|
1869
|
+
Number(initPayload.totalChunks) || Math.ceil(file.size / chunkSize);
|
|
1870
|
+
let uploadedBytes = 0;
|
|
1871
|
+
|
|
1872
|
+
for (let index = 0; index < totalChunks; index += 1) {
|
|
1873
|
+
const start = index * chunkSize;
|
|
1874
|
+
const end = Math.min(start + chunkSize, file.size);
|
|
1875
|
+
const chunk = file.slice(start, end);
|
|
1876
|
+
const q = new URLSearchParams({
|
|
1877
|
+
uploadId,
|
|
1878
|
+
index: String(index)
|
|
1879
|
+
});
|
|
1880
|
+
await xhrSendBinary(`/media/upload-chunk?${q.toString()}`, chunk, {
|
|
1881
|
+
timeoutMs: CHUNKED_PROXY_TIMEOUT_MS,
|
|
1882
|
+
contentType: "application/octet-stream",
|
|
1883
|
+
onProgress: (loaded) => {
|
|
1884
|
+
if (typeof onProgress !== "function") return;
|
|
1885
|
+
const overall = Math.min(file.size, uploadedBytes + loaded);
|
|
1886
|
+
onProgress(Math.round((overall / file.size) * 100));
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
uploadedBytes = end;
|
|
1890
|
+
if (typeof onProgress === "function") {
|
|
1891
|
+
onProgress(Math.round((uploadedBytes / file.size) * 100));
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
const completeRes = await fetch("/media/upload-complete", {
|
|
1896
|
+
method: "POST",
|
|
1897
|
+
headers: { "Content-Type": "application/json" },
|
|
1898
|
+
body: JSON.stringify({ uploadId })
|
|
1899
|
+
});
|
|
1900
|
+
let completePayload = {};
|
|
1901
|
+
try {
|
|
1902
|
+
completePayload = await completeRes.json();
|
|
1903
|
+
} catch {
|
|
1904
|
+
completePayload = {};
|
|
1905
|
+
}
|
|
1906
|
+
if (
|
|
1907
|
+
!completeRes.ok ||
|
|
1908
|
+
completePayload.status === "failed" ||
|
|
1909
|
+
!completePayload.assetId
|
|
1910
|
+
) {
|
|
1911
|
+
throw new Error(
|
|
1912
|
+
completePayload.error || `Upload complete failed (${completeRes.status})`
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
if (typeof onProgress === "function") onProgress(100);
|
|
1916
|
+
return completePayload;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1814
1919
|
async function uploadFile(file, onProgress) {
|
|
1815
1920
|
try {
|
|
1816
1921
|
const direct = await uploadViaCloudinaryDirect(file, onProgress);
|
|
1817
1922
|
if (direct) return direct;
|
|
1818
1923
|
} catch (err) {
|
|
1819
1924
|
const msg = err?.message || String(err);
|
|
1925
|
+
// Fall back to proxy upload only when Cloudinary is unavailable.
|
|
1820
1926
|
if (!/not configured|Upload sign failed \(404\)/i.test(msg)) {
|
|
1821
1927
|
throw err;
|
|
1822
1928
|
}
|
|
1823
1929
|
}
|
|
1824
1930
|
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
try {
|
|
1828
|
-
const payload = await uploadFileWithProgress(file, { onProgress, attempt });
|
|
1829
|
-
return payload;
|
|
1830
|
-
} catch (err) {
|
|
1831
|
-
lastErr = err;
|
|
1832
|
-
const msg = err?.message || String(err);
|
|
1833
|
-
if (/Upload failed \(413\)/i.test(msg)) break;
|
|
1834
|
-
if (attempt >= UPLOAD_MAX_RETRIES) break;
|
|
1835
|
-
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
throw lastErr || new Error("Upload failed");
|
|
1931
|
+
// Always chunk through the CMS for Object Storage / local — avoids Replit 413.
|
|
1932
|
+
return uploadViaChunkedProxy(file, onProgress);
|
|
1839
1933
|
}
|
|
1840
1934
|
|
|
1841
1935
|
async function addAssetFromFile(file, queueIndex, queueTotal) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.58",
|
|
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.
|
|
20
|
+
"@tomorrowos/sdk": "^0.9.58",
|
|
21
21
|
"dotenv": "^17.2.3",
|
|
22
22
|
"tsx": "^4.19.0"
|
|
23
23
|
},
|