@lotics/app-sdk 0.7.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/hooks.js +18 -4
- package/dist/src/index.d.ts +2 -0
- package/dist/src/mock.d.ts +45 -0
- package/dist/src/mock.js +65 -0
- package/dist/src/mount.d.ts +26 -2
- package/dist/src/mount.js +3 -1
- package/dist/src/password_gate.d.ts +29 -0
- package/dist/src/password_gate.js +142 -0
- package/dist/src/rpc.js +126 -68
- package/dist/src/upload/optimize.d.ts +30 -0
- package/dist/src/upload/optimize.js +182 -0
- package/dist/src/upload/pipeline.d.ts +52 -0
- package/dist/src/upload/pipeline.js +52 -0
- package/dist/src/upload/transport.d.ts +42 -0
- package/dist/src/upload/transport.js +128 -0
- package/package.json +1 -1
package/dist/src/hooks.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { useCallback, useEffect, useState } from "react";
|
|
17
17
|
import { rpc } from "./rpc.js";
|
|
18
|
+
import { getMockRows } from "./mock.js";
|
|
18
19
|
export function useWorkflow(alias) {
|
|
19
20
|
return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
|
|
20
21
|
}
|
|
@@ -26,12 +27,25 @@ export function useQuery(alias, params) {
|
|
|
26
27
|
// refetch callback mutates only this counter; state transitions still
|
|
27
28
|
// happen inside the effect so loading / error / rows flow consistently.
|
|
28
29
|
const [refetchToken, setRefetchToken] = useState(0);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// Initialize from the fixture when mock mode is on and the app registered
|
|
31
|
+
// rows for this alias — avoids a flash of `loading: true` on first paint.
|
|
32
|
+
// Computed lazily so subsequent renders don't re-read `window.location`.
|
|
33
|
+
const [state, setState] = useState(() => {
|
|
34
|
+
const mockRows = getMockRows(alias);
|
|
35
|
+
if (mockRows)
|
|
36
|
+
return { rows: mockRows, loading: false, error: null };
|
|
37
|
+
return { rows: [], loading: true, error: null };
|
|
33
38
|
});
|
|
34
39
|
useEffect(() => {
|
|
40
|
+
// Re-check on every effect run so HMR updates to the fixture (mount-time
|
|
41
|
+
// re-registration) propagate without a hard reload. When the fixture has
|
|
42
|
+
// an entry we short-circuit the RPC — partial mocking still works because
|
|
43
|
+
// aliases without fixture entries fall through to the live path below.
|
|
44
|
+
const mockRows = getMockRows(alias);
|
|
45
|
+
if (mockRows) {
|
|
46
|
+
setState({ rows: mockRows, loading: false, error: null });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
35
49
|
let cancelled = false;
|
|
36
50
|
setState((s) => ({ rows: s.rows, loading: true, error: null }));
|
|
37
51
|
rpc("query", { alias, params: params ?? {} })
|
package/dist/src/index.d.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* depending on packages/ui's React Native Web setup.
|
|
11
11
|
*/
|
|
12
12
|
export { mount } from "./mount.js";
|
|
13
|
+
export type { MountOptions } from "./mount.js";
|
|
13
14
|
export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
14
15
|
export type { UploadedFile } from "./hooks.js";
|
|
15
16
|
export { rpc } from "./rpc.js";
|
|
16
17
|
export type { RpcOp } from "./rpc.js";
|
|
18
|
+
export type { AppFixture } from "./mock.js";
|
|
17
19
|
export type { AppWorkflows, AppQueries } from "./types.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo / design-time fixture support for `useQuery`.
|
|
3
|
+
*
|
|
4
|
+
* Activation contract:
|
|
5
|
+
*
|
|
6
|
+
* 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
|
|
7
|
+
* is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
|
|
8
|
+
* app declared in `package.json#lotics.queries`.
|
|
9
|
+
* 2. At runtime, the user (or a screenshot script) loads the app with the
|
|
10
|
+
* `?__mock=1` URL search param. Without that param the SDK ignores the
|
|
11
|
+
* fixture entirely and `useQuery` flows through the RPC bridge as usual.
|
|
12
|
+
*
|
|
13
|
+
* The two-step gate keeps demo data shipping in the bundle from leaking into
|
|
14
|
+
* normal traffic — the param namespace (`__mock` prefix) is reserved and
|
|
15
|
+
* unlikely to collide with app-side query state. Apps that don't pass a
|
|
16
|
+
* fixture pay nothing: `getMockRows` returns `null` for every alias and the
|
|
17
|
+
* hook path is unchanged.
|
|
18
|
+
*
|
|
19
|
+
* What's deliberately *not* mocked yet:
|
|
20
|
+
* - `useWorkflow` — mutations have side effects (notifications, audit
|
|
21
|
+
* trail). A workflow mock would have to also produce realistic followup
|
|
22
|
+
* state, which is more design than this iteration warrants.
|
|
23
|
+
* - `useFileUpload` — same reason.
|
|
24
|
+
*
|
|
25
|
+
* If those become needed, extend `AppFixture` with `workflows`/`uploads` and
|
|
26
|
+
* route from each hook.
|
|
27
|
+
*/
|
|
28
|
+
export interface AppFixture {
|
|
29
|
+
/** Map of query alias → rows the hook should return when mock mode is on. */
|
|
30
|
+
queries?: Record<string, Array<Record<string, unknown>>>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Called by `mount({ fixture })`. Module-level state because the SDK has no
|
|
34
|
+
* React context boundary around the iframe — every hook resolves against the
|
|
35
|
+
* same registration. Calling twice replaces (last-write-wins) which is fine
|
|
36
|
+
* for HMR.
|
|
37
|
+
*/
|
|
38
|
+
export declare function registerMockFixture(fixture: AppFixture | undefined): void;
|
|
39
|
+
/**
|
|
40
|
+
* Returns the fixture rows for an alias when mock mode is active *and* the
|
|
41
|
+
* fixture has an entry for that alias. Otherwise null — the hook falls
|
|
42
|
+
* through to the real RPC path. The distinction matters: an app may mock
|
|
43
|
+
* only some queries and let the rest flow through to real data.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getMockRows(alias: string): Array<Record<string, unknown>> | null;
|
package/dist/src/mock.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo / design-time fixture support for `useQuery`.
|
|
3
|
+
*
|
|
4
|
+
* Activation contract:
|
|
5
|
+
*
|
|
6
|
+
* 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
|
|
7
|
+
* is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
|
|
8
|
+
* app declared in `package.json#lotics.queries`.
|
|
9
|
+
* 2. At runtime, the user (or a screenshot script) loads the app with the
|
|
10
|
+
* `?__mock=1` URL search param. Without that param the SDK ignores the
|
|
11
|
+
* fixture entirely and `useQuery` flows through the RPC bridge as usual.
|
|
12
|
+
*
|
|
13
|
+
* The two-step gate keeps demo data shipping in the bundle from leaking into
|
|
14
|
+
* normal traffic — the param namespace (`__mock` prefix) is reserved and
|
|
15
|
+
* unlikely to collide with app-side query state. Apps that don't pass a
|
|
16
|
+
* fixture pay nothing: `getMockRows` returns `null` for every alias and the
|
|
17
|
+
* hook path is unchanged.
|
|
18
|
+
*
|
|
19
|
+
* What's deliberately *not* mocked yet:
|
|
20
|
+
* - `useWorkflow` — mutations have side effects (notifications, audit
|
|
21
|
+
* trail). A workflow mock would have to also produce realistic followup
|
|
22
|
+
* state, which is more design than this iteration warrants.
|
|
23
|
+
* - `useFileUpload` — same reason.
|
|
24
|
+
*
|
|
25
|
+
* If those become needed, extend `AppFixture` with `workflows`/`uploads` and
|
|
26
|
+
* route from each hook.
|
|
27
|
+
*/
|
|
28
|
+
let registeredFixture;
|
|
29
|
+
/**
|
|
30
|
+
* Called by `mount({ fixture })`. Module-level state because the SDK has no
|
|
31
|
+
* React context boundary around the iframe — every hook resolves against the
|
|
32
|
+
* same registration. Calling twice replaces (last-write-wins) which is fine
|
|
33
|
+
* for HMR.
|
|
34
|
+
*/
|
|
35
|
+
export function registerMockFixture(fixture) {
|
|
36
|
+
registeredFixture = fixture;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* True iff the iframe URL carries the `__mock=1` activation flag AND a
|
|
40
|
+
* fixture was registered. Safe to call before mount — returns false when no
|
|
41
|
+
* fixture exists. Throws never; a malformed URL or missing `window` (SSR /
|
|
42
|
+
* jsdom without location) silently returns false.
|
|
43
|
+
*/
|
|
44
|
+
function isMockMode() {
|
|
45
|
+
if (!registeredFixture)
|
|
46
|
+
return false;
|
|
47
|
+
try {
|
|
48
|
+
return new URLSearchParams(window.location.search).get("__mock") === "1";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Returns the fixture rows for an alias when mock mode is active *and* the
|
|
56
|
+
* fixture has an entry for that alias. Otherwise null — the hook falls
|
|
57
|
+
* through to the real RPC path. The distinction matters: an app may mock
|
|
58
|
+
* only some queries and let the rest flow through to real data.
|
|
59
|
+
*/
|
|
60
|
+
export function getMockRows(alias) {
|
|
61
|
+
if (!isMockMode())
|
|
62
|
+
return null;
|
|
63
|
+
const rows = registeredFixture?.queries?.[alias];
|
|
64
|
+
return rows ?? null;
|
|
65
|
+
}
|
package/dist/src/mount.d.ts
CHANGED
|
@@ -10,9 +10,23 @@
|
|
|
10
10
|
* mount(<App />);
|
|
11
11
|
* ```
|
|
12
12
|
*
|
|
13
|
+
* Optional second argument registers a demo/design-time fixture. When the
|
|
14
|
+
* iframe is loaded with `?__mock=1`, `useQuery` returns rows from the
|
|
15
|
+
* fixture instead of hitting the RPC bridge. Without the URL flag the
|
|
16
|
+
* fixture is inert — useful for taking screenshots or iterating on
|
|
17
|
+
* dashboard layouts before real data exists.
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* mount(<App />, {
|
|
21
|
+
* fixture: {
|
|
22
|
+
* queries: { customers: MOCK_CUSTOMERS, deals: MOCK_DEALS },
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
13
27
|
* `mount` wires up React 19's createRoot against `#root` in the iframe shell,
|
|
14
28
|
* sets up window.onerror/unhandledrejection forwarding so runtime crashes
|
|
15
|
-
* surface in the parent's debug pane
|
|
29
|
+
* surface in the parent's debug pane, registers the fixture (if any), and
|
|
16
30
|
* renders the user's tree.
|
|
17
31
|
*
|
|
18
32
|
* If the bundler doesn't ship #root in the user's `index.html`, we create it
|
|
@@ -20,4 +34,14 @@
|
|
|
20
34
|
* mount resilient.
|
|
21
35
|
*/
|
|
22
36
|
import type { ReactNode } from "react";
|
|
23
|
-
|
|
37
|
+
import { type AppFixture } from "./mock.js";
|
|
38
|
+
export interface MountOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Optional demo fixture. When the iframe URL contains `?__mock=1`,
|
|
41
|
+
* `useQuery(alias)` returns `fixture.queries[alias]` instead of calling
|
|
42
|
+
* the RPC bridge. Aliases not present in the fixture still flow through
|
|
43
|
+
* the real path — partial mocking is supported.
|
|
44
|
+
*/
|
|
45
|
+
fixture?: AppFixture;
|
|
46
|
+
}
|
|
47
|
+
export declare function mount(element: ReactNode, options?: MountOptions): void;
|
package/dist/src/mount.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
|
-
|
|
2
|
+
import { registerMockFixture } from "./mock.js";
|
|
3
|
+
export function mount(element, options = {}) {
|
|
4
|
+
registerMockFixture(options.fixture);
|
|
3
5
|
let container = document.getElementById("root");
|
|
4
6
|
if (!container) {
|
|
5
7
|
container = document.createElement("div");
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla DOM password overlay for password-protected public apps.
|
|
3
|
+
*
|
|
4
|
+
* The SDK can't assume the host app's React tree has mounted yet (this fires
|
|
5
|
+
* before any data call resolves), so the overlay is built with plain DOM
|
|
6
|
+
* APIs and self-contained inline styles. It rejects the boot promise only
|
|
7
|
+
* on `cancel()` — which is currently never wired to a button; the visitor
|
|
8
|
+
* either enters the correct password or leaves the page.
|
|
9
|
+
*
|
|
10
|
+
* One overlay at a time. Concurrent calls reuse the in-flight promise so a
|
|
11
|
+
* burst of failing API calls produces a single modal, not several stacked.
|
|
12
|
+
*/
|
|
13
|
+
interface OverlayController {
|
|
14
|
+
showError(message: string): void;
|
|
15
|
+
setSubmitting(submitting: boolean): void;
|
|
16
|
+
}
|
|
17
|
+
export interface PasswordPromptArgs {
|
|
18
|
+
/**
|
|
19
|
+
* Validates the entered password. Resolve to keep the overlay open on a
|
|
20
|
+
* wrong password (the SDK calls `controller.showError(...)` afterwards),
|
|
21
|
+
* or `accept()` to dismiss the overlay and resolve the prompt with the
|
|
22
|
+
* password the visitor typed.
|
|
23
|
+
*/
|
|
24
|
+
authenticate: (password: string, controller: OverlayController) => Promise<{
|
|
25
|
+
ok: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export declare function promptForPassword(args: PasswordPromptArgs): Promise<string>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla DOM password overlay for password-protected public apps.
|
|
3
|
+
*
|
|
4
|
+
* The SDK can't assume the host app's React tree has mounted yet (this fires
|
|
5
|
+
* before any data call resolves), so the overlay is built with plain DOM
|
|
6
|
+
* APIs and self-contained inline styles. It rejects the boot promise only
|
|
7
|
+
* on `cancel()` — which is currently never wired to a button; the visitor
|
|
8
|
+
* either enters the correct password or leaves the page.
|
|
9
|
+
*
|
|
10
|
+
* One overlay at a time. Concurrent calls reuse the in-flight promise so a
|
|
11
|
+
* burst of failing API calls produces a single modal, not several stacked.
|
|
12
|
+
*/
|
|
13
|
+
let activePrompt = null;
|
|
14
|
+
export function promptForPassword(args) {
|
|
15
|
+
if (activePrompt)
|
|
16
|
+
return activePrompt;
|
|
17
|
+
activePrompt = new Promise((resolve) => {
|
|
18
|
+
const root = document.createElement("div");
|
|
19
|
+
root.dataset.loticsAppPasswordGate = "true";
|
|
20
|
+
root.style.cssText = [
|
|
21
|
+
"position:fixed",
|
|
22
|
+
"inset:0",
|
|
23
|
+
"z-index:2147483647",
|
|
24
|
+
"background:rgba(15,23,42,0.55)",
|
|
25
|
+
"backdrop-filter:blur(8px)",
|
|
26
|
+
"-webkit-backdrop-filter:blur(8px)",
|
|
27
|
+
"display:flex",
|
|
28
|
+
"align-items:center",
|
|
29
|
+
"justify-content:center",
|
|
30
|
+
"padding:24px",
|
|
31
|
+
"font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Inter,sans-serif",
|
|
32
|
+
"color:#0f172a",
|
|
33
|
+
].join(";");
|
|
34
|
+
const card = document.createElement("form");
|
|
35
|
+
card.setAttribute("role", "dialog");
|
|
36
|
+
card.setAttribute("aria-modal", "true");
|
|
37
|
+
card.setAttribute("aria-labelledby", "lotics-app-password-title");
|
|
38
|
+
card.style.cssText = [
|
|
39
|
+
"background:#fff",
|
|
40
|
+
"border-radius:14px",
|
|
41
|
+
"padding:28px",
|
|
42
|
+
"max-width:380px",
|
|
43
|
+
"width:100%",
|
|
44
|
+
"box-shadow:0 25px 50px -12px rgba(0,0,0,0.25)",
|
|
45
|
+
"display:flex",
|
|
46
|
+
"flex-direction:column",
|
|
47
|
+
"gap:16px",
|
|
48
|
+
].join(";");
|
|
49
|
+
const title = document.createElement("h2");
|
|
50
|
+
title.id = "lotics-app-password-title";
|
|
51
|
+
title.textContent = "Password required";
|
|
52
|
+
title.style.cssText = "margin:0;font-size:18px;font-weight:600;letter-spacing:-0.01em;";
|
|
53
|
+
const subtitle = document.createElement("p");
|
|
54
|
+
subtitle.textContent = "Enter the password to access this app.";
|
|
55
|
+
subtitle.style.cssText = "margin:0;color:#475569;font-size:13.5px;";
|
|
56
|
+
const input = document.createElement("input");
|
|
57
|
+
input.type = "password";
|
|
58
|
+
input.autocomplete = "current-password";
|
|
59
|
+
input.required = true;
|
|
60
|
+
input.placeholder = "Password";
|
|
61
|
+
input.setAttribute("aria-label", "Password");
|
|
62
|
+
input.style.cssText = [
|
|
63
|
+
"width:100%",
|
|
64
|
+
"box-sizing:border-box",
|
|
65
|
+
"padding:10px 12px",
|
|
66
|
+
"border:1px solid #cbd5e1",
|
|
67
|
+
"border-radius:8px",
|
|
68
|
+
"font-size:14px",
|
|
69
|
+
"outline:none",
|
|
70
|
+
"transition:border-color 0.15s,box-shadow 0.15s",
|
|
71
|
+
].join(";");
|
|
72
|
+
input.addEventListener("focus", () => {
|
|
73
|
+
input.style.borderColor = "#2563eb";
|
|
74
|
+
input.style.boxShadow = "0 0 0 3px rgba(37,99,235,0.18)";
|
|
75
|
+
});
|
|
76
|
+
input.addEventListener("blur", () => {
|
|
77
|
+
input.style.borderColor = "#cbd5e1";
|
|
78
|
+
input.style.boxShadow = "none";
|
|
79
|
+
});
|
|
80
|
+
const error = document.createElement("div");
|
|
81
|
+
error.setAttribute("role", "alert");
|
|
82
|
+
error.style.cssText = "color:#dc2626;font-size:13px;min-height:18px;";
|
|
83
|
+
const submit = document.createElement("button");
|
|
84
|
+
submit.type = "submit";
|
|
85
|
+
submit.textContent = "Continue";
|
|
86
|
+
submit.style.cssText = [
|
|
87
|
+
"padding:10px 12px",
|
|
88
|
+
"background:#0f172a",
|
|
89
|
+
"color:#fff",
|
|
90
|
+
"border:none",
|
|
91
|
+
"border-radius:8px",
|
|
92
|
+
"font-size:14px",
|
|
93
|
+
"font-weight:500",
|
|
94
|
+
"cursor:pointer",
|
|
95
|
+
"transition:background 0.15s,opacity 0.15s",
|
|
96
|
+
].join(";");
|
|
97
|
+
const setSubmitting = (s) => {
|
|
98
|
+
submit.disabled = s;
|
|
99
|
+
input.disabled = s;
|
|
100
|
+
submit.textContent = s ? "Checking…" : "Continue";
|
|
101
|
+
submit.style.opacity = s ? "0.7" : "1";
|
|
102
|
+
submit.style.cursor = s ? "wait" : "pointer";
|
|
103
|
+
};
|
|
104
|
+
const showError = (message) => {
|
|
105
|
+
error.textContent = message;
|
|
106
|
+
};
|
|
107
|
+
card.append(title, subtitle, input, error, submit);
|
|
108
|
+
root.append(card);
|
|
109
|
+
document.body.append(root);
|
|
110
|
+
// Defer focus until the element is in layout — some browsers ignore focus
|
|
111
|
+
// on freshly-attached inputs without a microtask break.
|
|
112
|
+
queueMicrotask(() => input.focus());
|
|
113
|
+
card.addEventListener("submit", (e) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
const value = input.value;
|
|
116
|
+
if (!value) {
|
|
117
|
+
showError("Enter a password.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
showError("");
|
|
121
|
+
setSubmitting(true);
|
|
122
|
+
args
|
|
123
|
+
.authenticate(value, { showError, setSubmitting })
|
|
124
|
+
.then((res) => {
|
|
125
|
+
if (res.ok) {
|
|
126
|
+
root.remove();
|
|
127
|
+
activePrompt = null;
|
|
128
|
+
resolve(value);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
setSubmitting(false);
|
|
132
|
+
input.select();
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.catch((err) => {
|
|
136
|
+
setSubmitting(false);
|
|
137
|
+
showError(err instanceof Error ? err.message : "Something went wrong.");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return activePrompt;
|
|
142
|
+
}
|
package/dist/src/rpc.js
CHANGED
|
@@ -1,23 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* An app reaches the Lotics API one of two ways, chosen automatically:
|
|
5
|
-
*
|
|
6
|
-
* - **Bridged** — the app is embedded by a Lotics host (the authenticated
|
|
7
|
-
* product, or `lotics app dev`), which passes its origin via the
|
|
8
|
-
* `?lotics_host=` query param. The host holds the session; the app posts
|
|
9
|
-
* ops over postMessage and the host makes the API call. Used by internal
|
|
10
|
-
* apps — the member's credentials must never reach the app.
|
|
11
|
-
*
|
|
12
|
-
* - **Standalone** — the app is served on its own at `<slug>.lotics.app`
|
|
13
|
-
* with no host. It calls the public `/v1/apps/{id}/*` endpoints directly;
|
|
14
|
-
* those are anonymous-accessible for a publicly-shared app. The app holds
|
|
15
|
-
* no credentials, so there is nothing to protect.
|
|
16
|
-
*
|
|
17
|
-
* Wire protocol (bridged — must match `app_iframe_host.tsx`):
|
|
18
|
-
* app → host: { id, op, payload }
|
|
19
|
-
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
|
-
*/
|
|
1
|
+
import { promptForPassword } from "./password_gate.js";
|
|
2
|
+
import { runUploadPipeline } from "./upload/pipeline.js";
|
|
21
3
|
/** The embedding Lotics host's origin — present iff the app is bridged. */
|
|
22
4
|
const hostOrigin = new URLSearchParams(window.location.search).get("lotics_host");
|
|
23
5
|
export function rpc(op, payload) {
|
|
@@ -63,41 +45,134 @@ function rpcBridged(op, payload, host) {
|
|
|
63
45
|
// Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
|
|
64
46
|
// always bridged by the `lotics app dev` wrapper.
|
|
65
47
|
const API_BASE = "https://api.lotics.ai";
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
48
|
+
const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
|
|
49
|
+
/**
|
|
50
|
+
* Boot-time resolution result. Promise is shared so concurrent first calls
|
|
51
|
+
* (e.g. two `useQuery` hooks rendering simultaneously) coalesce into one
|
|
52
|
+
* `/by-subdomain` fetch and at most one password prompt.
|
|
53
|
+
*/
|
|
54
|
+
let bootPromise = null;
|
|
55
|
+
let sessionToken = null;
|
|
56
|
+
function sessionStorageKey(appId) {
|
|
57
|
+
return `lotics_app_session:${appId}`;
|
|
58
|
+
}
|
|
59
|
+
function readStoredSession(appId) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = window.localStorage.getItem(sessionStorageKey(appId));
|
|
62
|
+
if (!raw)
|
|
63
|
+
return null;
|
|
64
|
+
const parsed = JSON.parse(raw);
|
|
65
|
+
if (!parsed.token || !parsed.expires_at)
|
|
66
|
+
return null;
|
|
67
|
+
if (Date.parse(parsed.expires_at) <= Date.now()) {
|
|
68
|
+
window.localStorage.removeItem(sessionStorageKey(appId));
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return parsed.token;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function writeStoredSession(appId, token, expiresAt) {
|
|
78
|
+
try {
|
|
79
|
+
window.localStorage.setItem(sessionStorageKey(appId), JSON.stringify({ token, expires_at: expiresAt }));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// localStorage unavailable (Safari private mode, quota): the token still
|
|
83
|
+
// lives in memory for this page lifetime — that's good enough for a single
|
|
84
|
+
// session, the next reload simply re-prompts.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function clearStoredSession(appId) {
|
|
88
|
+
try {
|
|
89
|
+
window.localStorage.removeItem(sessionStorageKey(appId));
|
|
80
90
|
}
|
|
81
|
-
|
|
91
|
+
catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function boot() {
|
|
96
|
+
if (bootPromise)
|
|
97
|
+
return bootPromise;
|
|
98
|
+
const slug = window.location.hostname.split(".")[0];
|
|
99
|
+
const attempt = (async () => {
|
|
100
|
+
const info = (await apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`));
|
|
101
|
+
if (info.requires_password) {
|
|
102
|
+
const stored = readStoredSession(info.app_id);
|
|
103
|
+
if (stored) {
|
|
104
|
+
sessionToken = stored;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
await acquireSessionToken(info.app_id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return info;
|
|
111
|
+
})();
|
|
112
|
+
// Don't cache a rejection — a transient failure on first load would brick
|
|
113
|
+
// every later data call. Clear the slot so the next call retries.
|
|
114
|
+
attempt.catch(() => {
|
|
115
|
+
if (bootPromise === attempt)
|
|
116
|
+
bootPromise = null;
|
|
117
|
+
});
|
|
118
|
+
bootPromise = attempt;
|
|
119
|
+
return attempt;
|
|
120
|
+
}
|
|
121
|
+
async function acquireSessionToken(appId) {
|
|
122
|
+
await promptForPassword({
|
|
123
|
+
authenticate: async (password, controller) => {
|
|
124
|
+
try {
|
|
125
|
+
const res = (await apiCall("POST", `/v1/apps/${appId}/public/authenticate`, { password }, { skipAuth: true }));
|
|
126
|
+
sessionToken = res.session_token;
|
|
127
|
+
writeStoredSession(appId, res.session_token, res.expires_at);
|
|
128
|
+
return { ok: true };
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const message = err instanceof Error && err.message ? err.message : "Incorrect password.";
|
|
132
|
+
controller.showError(message);
|
|
133
|
+
return { ok: false };
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
82
137
|
}
|
|
83
|
-
async function apiCall(method, path, body) {
|
|
138
|
+
async function apiCall(method, path, body, opts) {
|
|
139
|
+
const headers = {};
|
|
140
|
+
if (body)
|
|
141
|
+
headers["content-type"] = "application/json";
|
|
142
|
+
if (sessionToken && !opts?.skipAuth) {
|
|
143
|
+
headers["authorization"] = `Bearer ${sessionToken}`;
|
|
144
|
+
}
|
|
84
145
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
85
146
|
method,
|
|
86
|
-
headers
|
|
147
|
+
headers,
|
|
87
148
|
body: body ? JSON.stringify(body) : undefined,
|
|
88
149
|
});
|
|
89
150
|
const text = await res.text();
|
|
90
|
-
|
|
91
|
-
|
|
151
|
+
let parsed = null;
|
|
152
|
+
if (text) {
|
|
92
153
|
try {
|
|
93
|
-
|
|
154
|
+
parsed = JSON.parse(text);
|
|
94
155
|
}
|
|
95
156
|
catch {
|
|
96
|
-
//
|
|
157
|
+
// not JSON — body left as raw text
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const errorBody = parsed;
|
|
162
|
+
// Password gate (re-)prompt: the stored token went stale because the owner
|
|
163
|
+
// rotated or cleared the password, or it was never present. Drop the
|
|
164
|
+
// current token, ask the visitor again, and retry the original call once.
|
|
165
|
+
if (res.status === 401 && errorBody?.error_code === PASSWORD_REQUIRED_CODE && !opts?.skipAuth) {
|
|
166
|
+
const appId = opts?.appId ?? (await boot()).app_id;
|
|
167
|
+
sessionToken = null;
|
|
168
|
+
clearStoredSession(appId);
|
|
169
|
+
await acquireSessionToken(appId);
|
|
170
|
+
return apiCall(method, path, body, { ...opts, appId });
|
|
97
171
|
}
|
|
172
|
+
const message = errorBody?.message ?? text;
|
|
98
173
|
throw new Error(message || `HTTP ${res.status}`);
|
|
99
174
|
}
|
|
100
|
-
return text ?
|
|
175
|
+
return parsed ?? (text ? text : {});
|
|
101
176
|
}
|
|
102
177
|
function rpcStandalone(op, payload) {
|
|
103
178
|
switch (op) {
|
|
@@ -110,39 +185,22 @@ function rpcStandalone(op, payload) {
|
|
|
110
185
|
}
|
|
111
186
|
}
|
|
112
187
|
async function standaloneQuery(p) {
|
|
113
|
-
const
|
|
114
|
-
const r = (await apiCall("POST", `/v1/apps/${
|
|
115
|
-
alias: p.alias,
|
|
116
|
-
params: p.params,
|
|
117
|
-
}));
|
|
188
|
+
const { app_id } = await boot();
|
|
189
|
+
const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
|
|
118
190
|
return { rows: r.rows ?? [] };
|
|
119
191
|
}
|
|
120
192
|
async function standaloneWorkflow(p) {
|
|
121
|
-
const
|
|
122
|
-
return apiCall("POST", `/v1/apps/${
|
|
193
|
+
const { app_id } = await boot();
|
|
194
|
+
return apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
|
|
123
195
|
}
|
|
124
196
|
async function standaloneUpload(file) {
|
|
125
197
|
if (!(file instanceof File)) {
|
|
126
198
|
throw new Error("upload payload must include a File");
|
|
127
199
|
}
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
file_size: file.size,
|
|
133
|
-
}));
|
|
134
|
-
const put = await fetch(init.upload_url, {
|
|
135
|
-
method: "PUT",
|
|
136
|
-
body: file,
|
|
137
|
-
headers: { "Content-Type": file.type },
|
|
200
|
+
const { app_id } = await boot();
|
|
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 }),
|
|
138
204
|
});
|
|
139
|
-
|
|
140
|
-
throw new Error(`Storage upload failed (${put.status})`);
|
|
141
|
-
}
|
|
142
|
-
const done = (await apiCall("POST", `/v1/apps/${appId}/files/complete`, {
|
|
143
|
-
file_id: init.file_id,
|
|
144
|
-
file_storage_key: init.file_storage_key,
|
|
145
|
-
filename: file.name,
|
|
146
|
-
}));
|
|
147
|
-
return done.file;
|
|
205
|
+
return uploaded;
|
|
148
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
|
+
}
|