@lotics/app-sdk 0.8.0 → 0.9.0

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/src/rpc.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { promptForPassword } from "./password_gate.js";
2
+ import { runUploadPipeline } from "./upload/pipeline.js";
2
3
  /** The embedding Lotics host's origin — present iff the app is bridged. */
3
4
  const hostOrigin = new URLSearchParams(window.location.search).get("lotics_host");
4
5
  export function rpc(op, payload) {
@@ -197,23 +198,9 @@ async function standaloneUpload(file) {
197
198
  throw new Error("upload payload must include a File");
198
199
  }
199
200
  const { app_id } = await boot();
200
- const init = (await apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, {
201
- filename: file.name,
202
- mime_type: file.type,
203
- file_size: file.size,
204
- }, { appId: app_id }));
205
- const put = await fetch(init.upload_url, {
206
- method: "PUT",
207
- body: file,
208
- headers: { "Content-Type": file.type },
201
+ const uploaded = await runUploadPipeline(file, {
202
+ initUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, input, { appId: app_id }),
203
+ completeUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/complete`, input, { appId: app_id }),
209
204
  });
210
- if (!put.ok) {
211
- throw new Error(`Storage upload failed (${put.status})`);
212
- }
213
- const done = (await apiCall("POST", `/v1/apps/${app_id}/files/complete`, {
214
- file_id: init.file_id,
215
- file_storage_key: init.file_storage_key,
216
- filename: file.name,
217
- }, { appId: app_id }));
218
- return done.file;
205
+ return uploaded;
219
206
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Browser-side image compression for app uploads.
3
+ *
4
+ * Phone-camera photos are huge (4–10 MB HEIC/JPEG) but documents only need
5
+ * legible bytes, not megapixel ones. Resize to ≤1280px on the long edge and
6
+ * re-encode as JPEG at q=0.75 before uploading — typically 10–20× smaller
7
+ * with no visible quality loss for the doc-scan use case.
8
+ *
9
+ * HEIC/HEIF / PNG / WebP are converted to JPEG. Non-image files (PDF, etc.)
10
+ * pass through unchanged. Falls through gracefully on any browser API gap
11
+ * — the original file is always returned as a usable upload candidate.
12
+ *
13
+ * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
14
+ * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
15
+ * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
16
+ * as a single drop-in.
17
+ */
18
+ export type OptimizationReason = "optimized" | "skipped_unsupported_format" | "skipped_environment_unsupported" | "skipped_decode_unavailable" | "skipped_invalid_dimensions" | "skipped_small_dimensions" | "skipped_canvas_unavailable" | "skipped_canvas_type_mismatch";
19
+ export interface OptimizationResult {
20
+ file: File;
21
+ optimized: boolean;
22
+ reason: OptimizationReason;
23
+ originalSizeBytes: number;
24
+ optimizedSizeBytes: number;
25
+ width: number;
26
+ height: number;
27
+ targetWidth: number;
28
+ targetHeight: number;
29
+ }
30
+ export declare function optimizeImageForUpload(file: File): Promise<OptimizationResult>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Browser-side image compression for app uploads.
3
+ *
4
+ * Phone-camera photos are huge (4–10 MB HEIC/JPEG) but documents only need
5
+ * legible bytes, not megapixel ones. Resize to ≤1280px on the long edge and
6
+ * re-encode as JPEG at q=0.75 before uploading — typically 10–20× smaller
7
+ * with no visible quality loss for the doc-scan use case.
8
+ *
9
+ * HEIC/HEIF / PNG / WebP are converted to JPEG. Non-image files (PDF, etc.)
10
+ * pass through unchanged. Falls through gracefully on any browser API gap
11
+ * — the original file is always returned as a usable upload candidate.
12
+ *
13
+ * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
14
+ * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
15
+ * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
16
+ * as a single drop-in.
17
+ */
18
+ const MAX_IMAGE_DIMENSION_PX = 1280;
19
+ const JPEG_QUALITY = 0.75;
20
+ const CONVERTIBLE_EXTENSION_PATTERN = /\.(heic|heif|png|webp)$/i;
21
+ export async function optimizeImageForUpload(file) {
22
+ const format = getOptimizableFormat(file);
23
+ const mimeType = format?.outputMimeType;
24
+ const unsupportedReason = getUnsupportedReason(file, mimeType);
25
+ if (unsupportedReason)
26
+ return unchanged(file, unsupportedReason);
27
+ if (!mimeType || !format)
28
+ return unchanged(file, "skipped_unsupported_format");
29
+ const loaded = await loadImage(file);
30
+ if (loaded.status === "decode_unavailable")
31
+ return unchanged(file, "skipped_decode_unavailable");
32
+ const { source, width, height } = loaded;
33
+ if (width <= 0 || height <= 0) {
34
+ closeSource(source);
35
+ return unchanged(file, "skipped_invalid_dimensions");
36
+ }
37
+ if (Math.max(width, height) <= MAX_IMAGE_DIMENSION_PX) {
38
+ closeSource(source);
39
+ return unchanged(file, "skipped_small_dimensions", width, height);
40
+ }
41
+ const { width: targetWidth, height: targetHeight } = scale(width, height);
42
+ const canvas = document.createElement("canvas");
43
+ canvas.width = targetWidth;
44
+ canvas.height = targetHeight;
45
+ const ctx = canvas.getContext("2d");
46
+ if (!ctx || typeof canvas.toBlob !== "function") {
47
+ closeSource(source);
48
+ return unchanged(file, "skipped_canvas_unavailable", width, height);
49
+ }
50
+ ctx.imageSmoothingEnabled = true;
51
+ ctx.imageSmoothingQuality = "high";
52
+ ctx.drawImage(source, 0, 0, targetWidth, targetHeight);
53
+ closeSource(source);
54
+ const blob = await canvasToBlob(canvas, mimeType);
55
+ if (blob.type !== mimeType) {
56
+ return unchanged(file, "skipped_canvas_type_mismatch", width, height, targetWidth, targetHeight);
57
+ }
58
+ return {
59
+ file: new File([blob], format.outputFilename, {
60
+ type: mimeType,
61
+ lastModified: file.lastModified,
62
+ }),
63
+ optimized: true,
64
+ reason: "optimized",
65
+ originalSizeBytes: file.size,
66
+ optimizedSizeBytes: blob.size,
67
+ width,
68
+ height,
69
+ targetWidth,
70
+ targetHeight,
71
+ };
72
+ }
73
+ function getUnsupportedReason(file, mimeType) {
74
+ void file;
75
+ if (mimeType === undefined)
76
+ return "skipped_unsupported_format";
77
+ if (typeof window === "undefined" ||
78
+ typeof document === "undefined" ||
79
+ typeof document.createElement !== "function" ||
80
+ typeof URL.createObjectURL !== "function" ||
81
+ typeof URL.revokeObjectURL !== "function" ||
82
+ typeof Image === "undefined") {
83
+ return "skipped_environment_unsupported";
84
+ }
85
+ return undefined;
86
+ }
87
+ function getOptimizableFormat(file) {
88
+ const mimeType = normalizeMimeType(file.type);
89
+ if (!mimeType)
90
+ return undefined;
91
+ if (mimeType === "image/jpeg") {
92
+ return { outputMimeType: "image/jpeg", outputFilename: file.name };
93
+ }
94
+ // PNG / WebP / HEIC / HEIF → JPEG (transparency lost on PNG; acceptable
95
+ // for document uploads where we explicitly opt into lossy compression).
96
+ return { outputMimeType: "image/jpeg", outputFilename: replaceExtensionWithJpeg(file.name) };
97
+ }
98
+ function normalizeMimeType(mimeType) {
99
+ const m = mimeType.toLowerCase();
100
+ if (m === "image/jpeg" || m === "image/jpg")
101
+ return "image/jpeg";
102
+ if (m === "image/heic" || m === "image/heif")
103
+ return m;
104
+ if (m === "image/png" || m === "image/webp")
105
+ return m;
106
+ return undefined;
107
+ }
108
+ function replaceExtensionWithJpeg(filename) {
109
+ if (CONVERTIBLE_EXTENSION_PATTERN.test(filename)) {
110
+ return filename.replace(CONVERTIBLE_EXTENSION_PATTERN, ".jpg");
111
+ }
112
+ return `${filename}.jpg`;
113
+ }
114
+ function unchanged(file, reason, width = 0, height = 0, targetWidth = width, targetHeight = height) {
115
+ return {
116
+ file,
117
+ optimized: false,
118
+ reason,
119
+ originalSizeBytes: file.size,
120
+ optimizedSizeBytes: file.size,
121
+ width,
122
+ height,
123
+ targetWidth,
124
+ targetHeight,
125
+ };
126
+ }
127
+ function closeSource(source) {
128
+ if ("close" in source && typeof source.close === "function")
129
+ source.close();
130
+ }
131
+ function scale(width, height) {
132
+ const largest = Math.max(width, height);
133
+ if (largest <= MAX_IMAGE_DIMENSION_PX)
134
+ return { width, height };
135
+ const factor = MAX_IMAGE_DIMENSION_PX / largest;
136
+ return {
137
+ width: Math.max(1, Math.round(width * factor)),
138
+ height: Math.max(1, Math.round(height * factor)),
139
+ };
140
+ }
141
+ async function loadImage(file) {
142
+ // Prefer createImageBitmap — off-main-thread decode, more reliable on
143
+ // mobile under memory pressure where Image() silently fails.
144
+ if (typeof createImageBitmap === "function") {
145
+ try {
146
+ const bitmap = await createImageBitmap(file);
147
+ return { status: "decoded", source: bitmap, width: bitmap.width, height: bitmap.height };
148
+ }
149
+ catch {
150
+ // unsupported format / corrupt — fall back to Image()
151
+ }
152
+ }
153
+ const objectUrl = URL.createObjectURL(file);
154
+ return new Promise((resolve) => {
155
+ const image = new Image();
156
+ const cleanup = () => {
157
+ image.onload = null;
158
+ image.onerror = null;
159
+ URL.revokeObjectURL(objectUrl);
160
+ };
161
+ image.onload = () => {
162
+ cleanup();
163
+ resolve({ status: "decoded", source: image, width: image.naturalWidth, height: image.naturalHeight });
164
+ };
165
+ image.onerror = () => {
166
+ cleanup();
167
+ resolve({ status: "decode_unavailable" });
168
+ };
169
+ image.src = objectUrl;
170
+ });
171
+ }
172
+ async function canvasToBlob(canvas, mimeType) {
173
+ return new Promise((resolve, reject) => {
174
+ canvas.toBlob((blob) => {
175
+ if (!blob) {
176
+ reject(new Error("Canvas export returned no data during upload optimization"));
177
+ return;
178
+ }
179
+ resolve(blob);
180
+ }, mimeType, JPEG_QUALITY);
181
+ });
182
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Orchestrator for the app-upload pipeline.
3
+ *
4
+ * File → optimize (image only) → request presigned URL →
5
+ * PUT to storage (with retry) → complete → ProcessedFile
6
+ *
7
+ * Each step is a focused module (see `optimize.ts`, `transport.ts`); this
8
+ * file just wires them together so `useFileUpload` callers get one robust
9
+ * "uploaded" promise instead of a bare-fetch happy path.
10
+ *
11
+ * Surface is intentionally minimal: the only knob is an optional
12
+ * `AbortSignal` for caller cancellation. Optimization, retries, and
13
+ * timeouts use platform-internal defaults — same conventions as the
14
+ * in-Lotics direct-upload pipeline.
15
+ */
16
+ interface UploadInitResponse {
17
+ upload_url: string;
18
+ file_id: string;
19
+ file_storage_key: string;
20
+ }
21
+ interface CompleteResponseFile {
22
+ id: string;
23
+ filename: string;
24
+ mime_type: string;
25
+ url?: string;
26
+ thumbnail_url?: string;
27
+ preview_url?: string;
28
+ }
29
+ /**
30
+ * Caller injects the RPC primitives so this module stays decoupled from the
31
+ * SDK's auth / bootstrap layer. The pipeline targets a specific app's upload
32
+ * endpoints via the supplied `initUpload` and `completeUpload`.
33
+ */
34
+ export interface UploadRpc {
35
+ initUpload(input: {
36
+ filename: string;
37
+ mime_type: string;
38
+ file_size: number;
39
+ }): Promise<UploadInitResponse>;
40
+ completeUpload(input: {
41
+ file_id: string;
42
+ file_storage_key: string;
43
+ filename: string;
44
+ }): Promise<{
45
+ file: CompleteResponseFile;
46
+ }>;
47
+ }
48
+ export interface RunUploadPipelineOptions {
49
+ signal?: AbortSignal;
50
+ }
51
+ export declare function runUploadPipeline(file: File, rpc: UploadRpc, options?: RunUploadPipelineOptions): Promise<CompleteResponseFile>;
52
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Orchestrator for the app-upload pipeline.
3
+ *
4
+ * File → optimize (image only) → request presigned URL →
5
+ * PUT to storage (with retry) → complete → ProcessedFile
6
+ *
7
+ * Each step is a focused module (see `optimize.ts`, `transport.ts`); this
8
+ * file just wires them together so `useFileUpload` callers get one robust
9
+ * "uploaded" promise instead of a bare-fetch happy path.
10
+ *
11
+ * Surface is intentionally minimal: the only knob is an optional
12
+ * `AbortSignal` for caller cancellation. Optimization, retries, and
13
+ * timeouts use platform-internal defaults — same conventions as the
14
+ * in-Lotics direct-upload pipeline.
15
+ */
16
+ import { optimizeImageForUpload } from "./optimize.js";
17
+ import { putToStorageWithRetry } from "./transport.js";
18
+ export async function runUploadPipeline(file, rpc, options = {}) {
19
+ const { signal } = options;
20
+ // 1. Compress images. Non-image files return unchanged. Failures here are
21
+ // intentionally swallowed — the user shouldn't see an upload error
22
+ // because canvas threw; we just upload the original bytes.
23
+ let candidate = file;
24
+ try {
25
+ const optimized = await optimizeImageForUpload(file);
26
+ candidate = optimized.file;
27
+ }
28
+ catch {
29
+ // fall through with original file
30
+ }
31
+ if (signal?.aborted)
32
+ throw new Error("Upload aborted");
33
+ // 2. Ask the backend for a presigned upload URL.
34
+ const init = await rpc.initUpload({
35
+ filename: candidate.name,
36
+ mime_type: candidate.type,
37
+ file_size: candidate.size,
38
+ });
39
+ // 3. PUT the bytes with timeout + retry. This is the mobile-network
40
+ // failure point.
41
+ const putResponse = await putToStorageWithRetry(init.upload_url, candidate, signal);
42
+ if (!putResponse.ok) {
43
+ throw new Error(`Storage upload failed (${putResponse.status}). Please check your connection and try again.`);
44
+ }
45
+ // 4. Finalize — the server validates the object and creates the file row.
46
+ const { file: uploaded } = await rpc.completeUpload({
47
+ file_id: init.file_id,
48
+ file_storage_key: init.file_storage_key,
49
+ filename: candidate.name,
50
+ });
51
+ return uploaded;
52
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Transport-layer helpers for the SDK upload pipeline.
3
+ *
4
+ * Two concerns this module owns:
5
+ *
6
+ * 1. **Per-request timeout.** A bare `fetch` hangs forever if the network is
7
+ * unreachable; we wrap it in an `AbortController` so a stuck request fails
8
+ * after `UPLOAD_TIMEOUT_MS` instead of leaving the user staring at a
9
+ * spinner.
10
+ *
11
+ * 2. **Retry with exponential backoff** for the presigned `PUT` to object
12
+ * storage — the single most failure-prone step on mobile networks. We
13
+ * retry on network errors / 5xx / timeouts up to `UPLOAD_PUT_MAX_ATTEMPTS`
14
+ * with 1s → 2s → 4s waits between attempts. We do NOT retry on 4xx
15
+ * (client error, retrying won't help) or on caller-initiated aborts.
16
+ *
17
+ * Mirrors the conventions in `frontend/lib/api_utils.ts` and
18
+ * `frontend/lib/file_upload.ts`, simplified for the SDK (no logger, no
19
+ * correlation headers).
20
+ */
21
+ export declare const UPLOAD_TIMEOUT_MS: number;
22
+ export declare class UploadTimeoutError extends Error {
23
+ readonly url: string;
24
+ readonly timeoutMs: number;
25
+ readonly name = "UploadTimeoutError";
26
+ constructor(url: string, timeoutMs: number);
27
+ }
28
+ export declare class UploadAbortedError extends Error {
29
+ readonly name = "UploadAbortedError";
30
+ constructor();
31
+ }
32
+ /** `fetch` with timeout + optional external `AbortSignal`. */
33
+ export declare function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response>;
34
+ /**
35
+ * Presigned-PUT to object storage with retry + backoff.
36
+ *
37
+ * Retries network errors, timeouts, and 5xx responses up to
38
+ * `UPLOAD_PUT_MAX_ATTEMPTS` times. A 4xx response is returned to the caller
39
+ * unchanged (retrying a 403/400 won't help; the caller decides). Aborts
40
+ * propagate immediately without retry.
41
+ */
42
+ export declare function putToStorageWithRetry(uploadUrl: string, file: File, signal: AbortSignal | undefined): Promise<Response>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Transport-layer helpers for the SDK upload pipeline.
3
+ *
4
+ * Two concerns this module owns:
5
+ *
6
+ * 1. **Per-request timeout.** A bare `fetch` hangs forever if the network is
7
+ * unreachable; we wrap it in an `AbortController` so a stuck request fails
8
+ * after `UPLOAD_TIMEOUT_MS` instead of leaving the user staring at a
9
+ * spinner.
10
+ *
11
+ * 2. **Retry with exponential backoff** for the presigned `PUT` to object
12
+ * storage — the single most failure-prone step on mobile networks. We
13
+ * retry on network errors / 5xx / timeouts up to `UPLOAD_PUT_MAX_ATTEMPTS`
14
+ * with 1s → 2s → 4s waits between attempts. We do NOT retry on 4xx
15
+ * (client error, retrying won't help) or on caller-initiated aborts.
16
+ *
17
+ * Mirrors the conventions in `frontend/lib/api_utils.ts` and
18
+ * `frontend/lib/file_upload.ts`, simplified for the SDK (no logger, no
19
+ * correlation headers).
20
+ */
21
+ export const UPLOAD_TIMEOUT_MS = 5 * 60 * 1000;
22
+ const UPLOAD_PUT_MAX_ATTEMPTS = 3;
23
+ const UPLOAD_PUT_BACKOFF_BASE_MS = 1000;
24
+ export class UploadTimeoutError extends Error {
25
+ url;
26
+ timeoutMs;
27
+ name = "UploadTimeoutError";
28
+ constructor(url, timeoutMs) {
29
+ super(`Upload request to ${url} timed out after ${timeoutMs}ms`);
30
+ this.url = url;
31
+ this.timeoutMs = timeoutMs;
32
+ }
33
+ }
34
+ export class UploadAbortedError extends Error {
35
+ name = "UploadAbortedError";
36
+ constructor() {
37
+ super("Upload aborted");
38
+ }
39
+ }
40
+ /** `fetch` with timeout + optional external `AbortSignal`. */
41
+ export async function fetchWithTimeout(url, init, timeoutMs) {
42
+ const controller = new AbortController();
43
+ let didTimeout = false;
44
+ const timeoutId = setTimeout(() => {
45
+ didTimeout = true;
46
+ controller.abort();
47
+ }, timeoutMs);
48
+ if (init.signal) {
49
+ if (init.signal.aborted) {
50
+ clearTimeout(timeoutId);
51
+ throw new UploadAbortedError();
52
+ }
53
+ init.signal.addEventListener("abort", () => controller.abort(), { once: true });
54
+ }
55
+ try {
56
+ return await fetch(url, { ...init, signal: controller.signal });
57
+ }
58
+ catch (err) {
59
+ if (didTimeout)
60
+ throw new UploadTimeoutError(url, timeoutMs);
61
+ if (init.signal?.aborted)
62
+ throw new UploadAbortedError();
63
+ throw err;
64
+ }
65
+ finally {
66
+ clearTimeout(timeoutId);
67
+ }
68
+ }
69
+ /**
70
+ * Presigned-PUT to object storage with retry + backoff.
71
+ *
72
+ * Retries network errors, timeouts, and 5xx responses up to
73
+ * `UPLOAD_PUT_MAX_ATTEMPTS` times. A 4xx response is returned to the caller
74
+ * unchanged (retrying a 403/400 won't help; the caller decides). Aborts
75
+ * propagate immediately without retry.
76
+ */
77
+ export async function putToStorageWithRetry(uploadUrl, file, signal) {
78
+ let lastError;
79
+ for (let attempt = 1; attempt <= UPLOAD_PUT_MAX_ATTEMPTS; attempt += 1) {
80
+ try {
81
+ const response = await fetchWithTimeout(uploadUrl, {
82
+ method: "PUT",
83
+ headers: { "Content-Type": file.type },
84
+ body: file,
85
+ signal,
86
+ }, UPLOAD_TIMEOUT_MS);
87
+ if (response.ok)
88
+ return response;
89
+ // 4xx is terminal — retrying a malformed/expired URL is pointless.
90
+ if (response.status >= 400 && response.status < 500)
91
+ return response;
92
+ // 5xx — fall through to retry.
93
+ lastError = new Error(`Storage PUT returned ${response.status}`);
94
+ }
95
+ catch (err) {
96
+ // Caller-initiated abort: stop immediately.
97
+ if (err instanceof UploadAbortedError)
98
+ throw err;
99
+ lastError = err;
100
+ }
101
+ if (attempt < UPLOAD_PUT_MAX_ATTEMPTS) {
102
+ const delayMs = UPLOAD_PUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
103
+ await sleep(delayMs, signal);
104
+ }
105
+ }
106
+ throw lastError instanceof Error ? lastError : new Error("Storage PUT failed");
107
+ }
108
+ function sleep(ms, signal) {
109
+ return new Promise((resolve, reject) => {
110
+ if (signal?.aborted) {
111
+ reject(new UploadAbortedError());
112
+ return;
113
+ }
114
+ const timeoutId = setTimeout(() => {
115
+ cleanup();
116
+ resolve();
117
+ }, ms);
118
+ const cleanup = () => {
119
+ clearTimeout(timeoutId);
120
+ signal?.removeEventListener("abort", onAbort);
121
+ };
122
+ const onAbort = () => {
123
+ cleanup();
124
+ reject(new UploadAbortedError());
125
+ };
126
+ signal?.addEventListener("abort", onAbort, { once: true });
127
+ });
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {