@aexol/spectral 0.9.212 → 0.9.216
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/backend/generated-images.d.ts +180 -0
- package/dist/backend/generated-images.d.ts.map +1 -0
- package/dist/backend/generated-images.js +483 -0
- package/dist/backend/machine-routing.d.ts +25 -0
- package/dist/backend/machine-routing.d.ts.map +1 -0
- package/dist/backend/machine-routing.js +35 -0
- package/dist/commands/logout.d.ts.map +1 -1
- package/dist/commands/logout.js +4 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +20 -2
- package/dist/extensions/browser/browser-service.d.ts +75 -1
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +600 -34
- package/dist/extensions/browser/index.d.ts +10 -4
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +44 -5
- package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
- package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
- package/dist/extensions/browser/stream/stream-tool.js +42 -14
- package/dist/extensions/browser/tools/core.d.ts.map +1 -1
- package/dist/extensions/browser/tools/core.js +63 -2
- package/dist/extensions/desktop-control/index.d.ts +55 -0
- package/dist/extensions/desktop-control/index.d.ts.map +1 -0
- package/dist/extensions/desktop-control/index.js +633 -0
- package/dist/extensions/desktop-control/keys.d.ts +33 -0
- package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
- package/dist/extensions/desktop-control/keys.js +170 -0
- package/dist/extensions/desktop-control/linux.d.ts +26 -0
- package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
- package/dist/extensions/desktop-control/linux.js +195 -0
- package/dist/extensions/desktop-control/macos.d.ts +53 -0
- package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
- package/dist/extensions/desktop-control/macos.js +382 -0
- package/dist/extensions/desktop-control/safety.d.ts +89 -0
- package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
- package/dist/extensions/desktop-control/safety.js +157 -0
- package/dist/extensions/desktop-control/types.d.ts +79 -0
- package/dist/extensions/desktop-control/types.d.ts.map +1 -0
- package/dist/extensions/desktop-control/types.js +10 -0
- package/dist/extensions/desktop-control/windows.d.ts +31 -0
- package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
- package/dist/extensions/desktop-control/windows.js +276 -0
- package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
- package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
- package/dist/extensions/desktop-screenshot/index.js +363 -17
- package/dist/extensions/image-generation/index.d.ts.map +1 -1
- package/dist/extensions/image-generation/index.js +133 -34
- package/dist/extensions/seo/index.d.ts.map +1 -1
- package/dist/extensions/seo/index.js +4 -0
- package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
- package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
- package/dist/extensions/seo/tools/backend-bridge.js +378 -0
- package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
- package/dist/extensions/seo/tools/full-audit.js +1 -1
- package/dist/extensions/seo/tools/report.d.ts +10 -0
- package/dist/extensions/seo/tools/report.d.ts.map +1 -1
- package/dist/extensions/seo/tools/report.js +1 -1
- package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
- package/dist/extensions/spectral-vision-fallback.js +11 -1
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +121 -4
- package/dist/generated/zeus/index.d.ts +527 -0
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/mcp/tool-registrar.d.ts.map +1 -1
- package/dist/mcp/tool-registrar.js +5 -2
- package/dist/mcp/types.d.ts +5 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/relay/dispatcher.d.ts +12 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +44 -42
- package/dist/sdk/ai/providers/faux.js +1 -1
- package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openai-completions.js +69 -16
- package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openrouter-images.js +58 -9
- package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
- package/dist/sdk/ai/providers/simple-options.js +2 -0
- package/dist/sdk/ai/types.d.ts +37 -1
- package/dist/sdk/ai/types.d.ts.map +1 -1
- package/dist/sdk/ai/utils/hosted-image-resolver.d.ts +60 -0
- package/dist/sdk/ai/utils/hosted-image-resolver.d.ts.map +1 -0
- package/dist/sdk/ai/utils/hosted-image-resolver.js +75 -0
- package/dist/sdk/ai/utils/image-source.d.ts +168 -0
- package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
- package/dist/sdk/ai/utils/image-source.js +487 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
- package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
- package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
- package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
- package/dist/server/agent-bridge.d.ts +14 -0
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +105 -26
- package/dist/server/handlers/queue.d.ts.map +1 -1
- package/dist/server/handlers/queue.js +11 -34
- package/dist/server/image-attachments.d.ts +109 -0
- package/dist/server/image-attachments.d.ts.map +1 -0
- package/dist/server/image-attachments.js +195 -0
- package/dist/server/session-stream.d.ts +67 -0
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +222 -23
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +10 -31
- package/dist/server/ui/terminal-confirm.d.ts +145 -0
- package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
- package/dist/server/ui/terminal-confirm.js +333 -0
- package/dist/server/wire.d.ts +30 -5
- package/dist/server/wire.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-hosted image storage client.
|
|
3
|
+
*
|
|
4
|
+
* Uploads raw image bytes to the Aexol backend so that sessions only ever
|
|
5
|
+
* carry a stable URL (`/generated-images/<imageId>`) instead of a base64
|
|
6
|
+
* payload. The three-step contract is:
|
|
7
|
+
*
|
|
8
|
+
* 1. `POST {backendUrl}/generated-images/upload-url`
|
|
9
|
+
* -> `{ imageId, uploadUrl, url, expiresIn, requiredHeaders? }`
|
|
10
|
+
* 2. `PUT <uploadUrl>` (presigned, ~600s)
|
|
11
|
+
* with `Content-Type` + every header from `requiredHeaders`
|
|
12
|
+
* 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
|
|
13
|
+
* -> `{ id, size, url }`
|
|
14
|
+
*
|
|
15
|
+
* Downloading works the other way round and has two flavours:
|
|
16
|
+
*
|
|
17
|
+
* A. `GET {backendUrl}/generated-images/<imageId>` (authenticated)
|
|
18
|
+
* -> 302 redirect to a short-lived presigned S3 GET.
|
|
19
|
+
* B. `GET {backendUrl}/generated-images/<imageId>/url`
|
|
20
|
+
* (authenticated) -> `{ url, expiresIn }` where `url` is a presigned
|
|
21
|
+
* S3 GET that needs NO headers at all.
|
|
22
|
+
*
|
|
23
|
+
* Flavour B is the one to hand to a third party that cannot authenticate
|
|
24
|
+
* against our backend (an LLM provider fetching an image, an `<img src>`,
|
|
25
|
+
* ...). See `resolveHostedImageUrl`.
|
|
26
|
+
*
|
|
27
|
+
* Errors from the backend have the shape
|
|
28
|
+
* `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
|
|
29
|
+
* `GeneratedImageUploadError` carrying the HTTP status and the step that
|
|
30
|
+
* failed, so callers can decide whether to degrade gracefully.
|
|
31
|
+
*
|
|
32
|
+
* Shared by `image_generate` (Phase 1) and — in a later phase — user
|
|
33
|
+
* screenshots/attachments, hence `kind` is a parameter rather than a constant.
|
|
34
|
+
*
|
|
35
|
+
* @module @spectral/backend/generated-images
|
|
36
|
+
*/
|
|
37
|
+
export type GeneratedImageKind = "generated" | "attachment";
|
|
38
|
+
export type UploadStep = "upload-url" | "put" | "confirm";
|
|
39
|
+
export interface UploadImageOptions {
|
|
40
|
+
/** Backend base url, e.g. `https://api.aexol.ai` (no trailing slash needed). */
|
|
41
|
+
backendUrl: string;
|
|
42
|
+
/** Machine JWT (`Authorization: Bearer <token>`). */
|
|
43
|
+
token: string;
|
|
44
|
+
/** Raw image bytes. */
|
|
45
|
+
bytes: Uint8Array;
|
|
46
|
+
/** MIME type of the bytes, e.g. `image/png`. */
|
|
47
|
+
contentType: string;
|
|
48
|
+
/** Storage bucket/kind on the backend. Defaults to `"generated"`. */
|
|
49
|
+
kind?: GeneratedImageKind;
|
|
50
|
+
/** Intrinsic pixel width, when known (optional metadata). */
|
|
51
|
+
width?: number;
|
|
52
|
+
/** Intrinsic pixel height, when known (optional metadata). */
|
|
53
|
+
height?: number;
|
|
54
|
+
/** Injectable for tests / alternative runtimes. Defaults to global `fetch`. */
|
|
55
|
+
fetchImpl?: typeof fetch;
|
|
56
|
+
/** Abort the whole sequence (e.g. tool cancellation). */
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
/** Per-request timeout. Defaults to 60s. */
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
}
|
|
61
|
+
export interface UploadImageResult {
|
|
62
|
+
imageId: string;
|
|
63
|
+
/** Backend-relative url (`/generated-images/<imageId>`) — safe to persist in a session. */
|
|
64
|
+
url: string;
|
|
65
|
+
size: number;
|
|
66
|
+
}
|
|
67
|
+
export interface UploadUrlResponse {
|
|
68
|
+
imageId: string;
|
|
69
|
+
uploadUrl: string;
|
|
70
|
+
url: string;
|
|
71
|
+
expiresIn?: number;
|
|
72
|
+
requiredHeaders?: Record<string, string>;
|
|
73
|
+
}
|
|
74
|
+
export interface ConfirmResponse {
|
|
75
|
+
id: string;
|
|
76
|
+
size: number;
|
|
77
|
+
url: string;
|
|
78
|
+
}
|
|
79
|
+
export interface ResolveHostedImageUrlOptions {
|
|
80
|
+
/** Backend base url, e.g. `https://api.aexol.ai`. */
|
|
81
|
+
backendUrl: string;
|
|
82
|
+
/** Machine JWT (`Authorization: Bearer <token>`). */
|
|
83
|
+
token: string;
|
|
84
|
+
/** Image id. Optional when `url` is given (it is parsed from there). */
|
|
85
|
+
imageId?: string;
|
|
86
|
+
/** Backend-relative (`/generated-images/<id>`) or absolute hosted url. */
|
|
87
|
+
url?: string;
|
|
88
|
+
/** Injectable for tests / alternative runtimes. Defaults to global `fetch`. */
|
|
89
|
+
fetchImpl?: typeof fetch;
|
|
90
|
+
signal?: AbortSignal;
|
|
91
|
+
/** Per-request timeout. Defaults to 60s. */
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
/** Diagnostics sink — failures are silent unless this is provided. */
|
|
94
|
+
onWarn?: (message: string) => void;
|
|
95
|
+
}
|
|
96
|
+
export interface HostedImageUrlResult {
|
|
97
|
+
/**
|
|
98
|
+
* Presigned S3 GET url that is fetchable WITHOUT any Authorization header.
|
|
99
|
+
* Valid for `expiresIn` seconds (typically 3600).
|
|
100
|
+
*/
|
|
101
|
+
url: string;
|
|
102
|
+
expiresIn: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract the hosted image id from a backend-relative path
|
|
106
|
+
* (`/generated-images/<id>`) or from an absolute url
|
|
107
|
+
* (`https://api.aexol.ai/generated-images/<id>?x=1`).
|
|
108
|
+
*
|
|
109
|
+
* Only the PATH is inspected: a query/fragment that happens to contain
|
|
110
|
+
* `/generated-images/<id>` does not make the url one of ours.
|
|
111
|
+
*
|
|
112
|
+
* Returns `null` when the value is not a hosted backend image reference, so
|
|
113
|
+
* callers can use it as a cheap "is this ours?" predicate.
|
|
114
|
+
*/
|
|
115
|
+
export declare function extractHostedImageId(urlOrPath: string): string | null;
|
|
116
|
+
/**
|
|
117
|
+
* The hosted image id carried by `url`, or `null` when `url` is not a hosted
|
|
118
|
+
* backend image reference. Alias of `extractHostedImageId` kept for callers
|
|
119
|
+
* that read better with a url-oriented name.
|
|
120
|
+
*/
|
|
121
|
+
export declare function hostedImageIdFromUrl(url: string): string | null;
|
|
122
|
+
/**
|
|
123
|
+
* True when `url` points at a backend-hosted image
|
|
124
|
+
* (`/generated-images/<id>`), either backend-relative or absolute.
|
|
125
|
+
*
|
|
126
|
+
* SECURITY: an absolute url is only "ours" when it actually points at OUR
|
|
127
|
+
* backend. `backendUrl` is therefore REQUIRED for absolute urls — without it
|
|
128
|
+
* there is nothing to compare against and the answer is `false`, so callers
|
|
129
|
+
* never attach the machine JWT to a host they cannot vouch for. Relative urls
|
|
130
|
+
* always match (they can only ever be resolved against the backend base url).
|
|
131
|
+
*/
|
|
132
|
+
export declare function isHostedImageUrl(url: string, backendUrl?: string): boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Drop cached presigned urls. Omit `imageId` to clear the whole cache (used
|
|
135
|
+
* by tests and after a logout/credential change).
|
|
136
|
+
*/
|
|
137
|
+
export declare function invalidateHostedImageUrl(imageId?: string, backendUrl?: string): void;
|
|
138
|
+
/**
|
|
139
|
+
* Turn a hosted image reference into a presigned url that a third party (LLM
|
|
140
|
+
* provider, browser) can fetch WITHOUT authenticating against our backend.
|
|
141
|
+
*
|
|
142
|
+
* `GET {backendUrl}/generated-images/<id>/url` with the machine JWT returns
|
|
143
|
+
* `{ url, expiresIn }`; the plain `GET /generated-images/<id>` endpoint is
|
|
144
|
+
* auth-gated and 302-redirects, so handing it to a provider only produces a
|
|
145
|
+
* 401 and a silently dropped image.
|
|
146
|
+
*
|
|
147
|
+
* Results are cached per `<backendUrl>|<imageId>` until
|
|
148
|
+
* `PRESIGNED_REFRESH_MARGIN_MS` before their expiry (see
|
|
149
|
+
* `invalidateHostedImageUrl`), so repeated requests for the same image do not
|
|
150
|
+
* each pay a round trip.
|
|
151
|
+
*
|
|
152
|
+
* NEVER throws and never logs: returns `null` on any failure (missing token,
|
|
153
|
+
* non-2xx, network error, malformed body) so callers can fall back to their
|
|
154
|
+
* previous behaviour. Pass `onWarn` to see why.
|
|
155
|
+
*/
|
|
156
|
+
export declare function resolveHostedImageUrl(options: ResolveHostedImageUrlOptions): Promise<HostedImageUrlResult | null>;
|
|
157
|
+
/**
|
|
158
|
+
* Thrown for every failure of the upload sequence (including presigned PUT
|
|
159
|
+
* failures). `status` is `undefined` for network-level errors.
|
|
160
|
+
*/
|
|
161
|
+
export declare class GeneratedImageUploadError extends Error {
|
|
162
|
+
readonly step: UploadStep;
|
|
163
|
+
readonly status?: number;
|
|
164
|
+
readonly type?: string;
|
|
165
|
+
constructor(message: string, options: {
|
|
166
|
+
step: UploadStep;
|
|
167
|
+
status?: number;
|
|
168
|
+
type?: string;
|
|
169
|
+
cause?: unknown;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Upload raw image bytes to backend storage and confirm them.
|
|
174
|
+
*
|
|
175
|
+
* @returns the image id, the backend-relative url and the confirmed byte size.
|
|
176
|
+
* @throws {GeneratedImageUploadError} on any HTTP or network failure, with a
|
|
177
|
+
* human-readable message taken from the backend `error.message` when present.
|
|
178
|
+
*/
|
|
179
|
+
export declare function uploadImage(options: UploadImageOptions): Promise<UploadImageResult>;
|
|
180
|
+
//# sourceMappingURL=generated-images.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generated-images.d.ts","sourceRoot":"","sources":["../../src/backend/generated-images.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-hosted image storage client.
|
|
3
|
+
*
|
|
4
|
+
* Uploads raw image bytes to the Aexol backend so that sessions only ever
|
|
5
|
+
* carry a stable URL (`/generated-images/<imageId>`) instead of a base64
|
|
6
|
+
* payload. The three-step contract is:
|
|
7
|
+
*
|
|
8
|
+
* 1. `POST {backendUrl}/generated-images/upload-url`
|
|
9
|
+
* -> `{ imageId, uploadUrl, url, expiresIn, requiredHeaders? }`
|
|
10
|
+
* 2. `PUT <uploadUrl>` (presigned, ~600s)
|
|
11
|
+
* with `Content-Type` + every header from `requiredHeaders`
|
|
12
|
+
* 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
|
|
13
|
+
* -> `{ id, size, url }`
|
|
14
|
+
*
|
|
15
|
+
* Downloading works the other way round and has two flavours:
|
|
16
|
+
*
|
|
17
|
+
* A. `GET {backendUrl}/generated-images/<imageId>` (authenticated)
|
|
18
|
+
* -> 302 redirect to a short-lived presigned S3 GET.
|
|
19
|
+
* B. `GET {backendUrl}/generated-images/<imageId>/url`
|
|
20
|
+
* (authenticated) -> `{ url, expiresIn }` where `url` is a presigned
|
|
21
|
+
* S3 GET that needs NO headers at all.
|
|
22
|
+
*
|
|
23
|
+
* Flavour B is the one to hand to a third party that cannot authenticate
|
|
24
|
+
* against our backend (an LLM provider fetching an image, an `<img src>`,
|
|
25
|
+
* ...). See `resolveHostedImageUrl`.
|
|
26
|
+
*
|
|
27
|
+
* Errors from the backend have the shape
|
|
28
|
+
* `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
|
|
29
|
+
* `GeneratedImageUploadError` carrying the HTTP status and the step that
|
|
30
|
+
* failed, so callers can decide whether to degrade gracefully.
|
|
31
|
+
*
|
|
32
|
+
* Shared by `image_generate` (Phase 1) and — in a later phase — user
|
|
33
|
+
* screenshots/attachments, hence `kind` is a parameter rather than a constant.
|
|
34
|
+
*
|
|
35
|
+
* @module @spectral/backend/generated-images
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
38
|
+
/**
|
|
39
|
+
* Presigned urls are refreshed this long before they actually expire, so a
|
|
40
|
+
* url handed to a provider (or an `<img src>`) never dies mid-flight.
|
|
41
|
+
* Mirrors the landing client's refresh margin.
|
|
42
|
+
*/
|
|
43
|
+
const PRESIGNED_REFRESH_MARGIN_MS = 60_000;
|
|
44
|
+
/** `/generated-images/<id>` — matched against the PATHNAME only. */
|
|
45
|
+
const HOSTED_IMAGE_PATH = /\/generated-images\/([^/?#\s]+)/;
|
|
46
|
+
/**
|
|
47
|
+
* Storage ids the backend accepts. Mirrors `ID_PATTERN` / `isValidId` from
|
|
48
|
+
* `backend/src/routes/generated-images/index.ts` so an id that could never
|
|
49
|
+
* exist server-side is never turned into a request (or an S3 key).
|
|
50
|
+
*/
|
|
51
|
+
const HOSTED_IMAGE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
52
|
+
const ABSOLUTE_URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
53
|
+
/** Upper bound on the presigned-url cache so a long session cannot grow it forever. */
|
|
54
|
+
const HOSTED_IMAGE_URL_CACHE_MAX = 200;
|
|
55
|
+
/** Assumed presigned-url lifetime when the backend omits/invalidates `expiresIn`. */
|
|
56
|
+
const DEFAULT_PRESIGNED_TTL_SECONDS = 3600;
|
|
57
|
+
/**
|
|
58
|
+
* `presigned url` cache keyed by `<backendUrl>|<token>|<imageId>`. Resolving a hosted
|
|
59
|
+
* image costs one authenticated round trip per image per request — with
|
|
60
|
+
* multi-turn image conversations (and retries) that is the same url minted
|
|
61
|
+
* over and over, so entries are reused until they are close to expiring.
|
|
62
|
+
*/
|
|
63
|
+
const hostedImageUrlCache = new Map();
|
|
64
|
+
/**
|
|
65
|
+
* Normalized `scheme://host[:port]` origin of an absolute url, or `null` for
|
|
66
|
+
* relative/unparseable input. `URL` already drops default ports (`:443` for
|
|
67
|
+
* https), so `https://api.test:443/x` and `https://api.test/x` compare equal.
|
|
68
|
+
*/
|
|
69
|
+
function originOf(value) {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(value).origin;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Path portion of an absolute url (`new URL(v).pathname`) or of a relative
|
|
79
|
+
* reference (everything before `?`/`#`). Query and fragment are NEVER part of
|
|
80
|
+
* the result, so `https://cdn.test/a.png?next=/generated-images/abc` cannot
|
|
81
|
+
* masquerade as one of our hosted images.
|
|
82
|
+
*/
|
|
83
|
+
function pathnameOf(value) {
|
|
84
|
+
if (ABSOLUTE_URL_SCHEME.test(value)) {
|
|
85
|
+
try {
|
|
86
|
+
return new URL(value).pathname;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return value.split("#")[0].split("?")[0];
|
|
93
|
+
}
|
|
94
|
+
/** Drop expired entries and evict the oldest ones past the size cap. */
|
|
95
|
+
function purgeHostedImageUrlCache(now = Date.now()) {
|
|
96
|
+
for (const [key, entry] of hostedImageUrlCache) {
|
|
97
|
+
if (entry.expiresAt <= now)
|
|
98
|
+
hostedImageUrlCache.delete(key);
|
|
99
|
+
}
|
|
100
|
+
// Map iteration is insertion-ordered, so the first key is the oldest.
|
|
101
|
+
while (hostedImageUrlCache.size > HOSTED_IMAGE_URL_CACHE_MAX) {
|
|
102
|
+
const oldest = hostedImageUrlCache.keys().next();
|
|
103
|
+
if (oldest.done)
|
|
104
|
+
break;
|
|
105
|
+
hostedImageUrlCache.delete(oldest.value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extract the hosted image id from a backend-relative path
|
|
110
|
+
* (`/generated-images/<id>`) or from an absolute url
|
|
111
|
+
* (`https://api.aexol.ai/generated-images/<id>?x=1`).
|
|
112
|
+
*
|
|
113
|
+
* Only the PATH is inspected: a query/fragment that happens to contain
|
|
114
|
+
* `/generated-images/<id>` does not make the url one of ours.
|
|
115
|
+
*
|
|
116
|
+
* Returns `null` when the value is not a hosted backend image reference, so
|
|
117
|
+
* callers can use it as a cheap "is this ours?" predicate.
|
|
118
|
+
*/
|
|
119
|
+
export function extractHostedImageId(urlOrPath) {
|
|
120
|
+
if (typeof urlOrPath !== "string")
|
|
121
|
+
return null;
|
|
122
|
+
const trimmed = urlOrPath.trim();
|
|
123
|
+
if (!trimmed)
|
|
124
|
+
return null;
|
|
125
|
+
const path = pathnameOf(trimmed);
|
|
126
|
+
if (!path)
|
|
127
|
+
return null;
|
|
128
|
+
const match = path.match(HOSTED_IMAGE_PATH);
|
|
129
|
+
const raw = match?.[1];
|
|
130
|
+
if (!raw)
|
|
131
|
+
return null;
|
|
132
|
+
// Strip an optional extension (`<id>.png`) — the id is the first segment.
|
|
133
|
+
const dot = raw.lastIndexOf(".");
|
|
134
|
+
let id = dot > 0 ? raw.slice(0, dot) : raw;
|
|
135
|
+
try {
|
|
136
|
+
id = decodeURIComponent(id);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Malformed percent-encoding: keep the raw segment.
|
|
140
|
+
}
|
|
141
|
+
if (!HOSTED_IMAGE_ID.test(id))
|
|
142
|
+
return null;
|
|
143
|
+
return id;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The hosted image id carried by `url`, or `null` when `url` is not a hosted
|
|
147
|
+
* backend image reference. Alias of `extractHostedImageId` kept for callers
|
|
148
|
+
* that read better with a url-oriented name.
|
|
149
|
+
*/
|
|
150
|
+
export function hostedImageIdFromUrl(url) {
|
|
151
|
+
return extractHostedImageId(url);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* True when `url` points at a backend-hosted image
|
|
155
|
+
* (`/generated-images/<id>`), either backend-relative or absolute.
|
|
156
|
+
*
|
|
157
|
+
* SECURITY: an absolute url is only "ours" when it actually points at OUR
|
|
158
|
+
* backend. `backendUrl` is therefore REQUIRED for absolute urls — without it
|
|
159
|
+
* there is nothing to compare against and the answer is `false`, so callers
|
|
160
|
+
* never attach the machine JWT to a host they cannot vouch for. Relative urls
|
|
161
|
+
* always match (they can only ever be resolved against the backend base url).
|
|
162
|
+
*/
|
|
163
|
+
export function isHostedImageUrl(url, backendUrl) {
|
|
164
|
+
if (typeof url !== "string")
|
|
165
|
+
return false;
|
|
166
|
+
const trimmed = url.trim();
|
|
167
|
+
if (!trimmed)
|
|
168
|
+
return false;
|
|
169
|
+
if (hostedImageIdFromUrl(trimmed) === null)
|
|
170
|
+
return false;
|
|
171
|
+
if (!ABSOLUTE_URL_SCHEME.test(trimmed))
|
|
172
|
+
return true;
|
|
173
|
+
const base = typeof backendUrl === "string" ? backendUrl.trim() : "";
|
|
174
|
+
if (!base)
|
|
175
|
+
return false;
|
|
176
|
+
const origin = originOf(trimmed);
|
|
177
|
+
const expected = originOf(base);
|
|
178
|
+
return origin !== null && expected !== null && origin === expected;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Cheap, non-cryptographic fingerprint of the credential: a cached presigned
|
|
182
|
+
* url must never be handed out under a different (possibly less privileged)
|
|
183
|
+
* machine JWT than the one that minted it, so the key carries the token too
|
|
184
|
+
* — without keeping the token itself in the map.
|
|
185
|
+
*/
|
|
186
|
+
function credentialFingerprint(token) {
|
|
187
|
+
let hash = 5381;
|
|
188
|
+
for (let index = 0; index < token.length; index += 1) {
|
|
189
|
+
hash = ((hash << 5) + hash + token.charCodeAt(index)) | 0;
|
|
190
|
+
}
|
|
191
|
+
return (hash >>> 0).toString(36);
|
|
192
|
+
}
|
|
193
|
+
function cacheKey(backendUrl, imageId, token) {
|
|
194
|
+
return `${baseUrlOf(backendUrl)}|${credentialFingerprint(token)}|${imageId}`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Drop cached presigned urls. Omit `imageId` to clear the whole cache (used
|
|
198
|
+
* by tests and after a logout/credential change).
|
|
199
|
+
*/
|
|
200
|
+
export function invalidateHostedImageUrl(imageId, backendUrl) {
|
|
201
|
+
const id = typeof imageId === "string" ? imageId.trim() : "";
|
|
202
|
+
if (!id) {
|
|
203
|
+
hostedImageUrlCache.clear();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const base = typeof backendUrl === "string" ? backendUrl.trim() : "";
|
|
207
|
+
const basePrefix = base ? `${baseUrlOf(base)}|` : "";
|
|
208
|
+
for (const key of [...hostedImageUrlCache.keys()]) {
|
|
209
|
+
if (!key.endsWith(`|${id}`))
|
|
210
|
+
continue;
|
|
211
|
+
if (basePrefix && !key.startsWith(basePrefix))
|
|
212
|
+
continue;
|
|
213
|
+
hostedImageUrlCache.delete(key);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Turn a hosted image reference into a presigned url that a third party (LLM
|
|
218
|
+
* provider, browser) can fetch WITHOUT authenticating against our backend.
|
|
219
|
+
*
|
|
220
|
+
* `GET {backendUrl}/generated-images/<id>/url` with the machine JWT returns
|
|
221
|
+
* `{ url, expiresIn }`; the plain `GET /generated-images/<id>` endpoint is
|
|
222
|
+
* auth-gated and 302-redirects, so handing it to a provider only produces a
|
|
223
|
+
* 401 and a silently dropped image.
|
|
224
|
+
*
|
|
225
|
+
* Results are cached per `<backendUrl>|<imageId>` until
|
|
226
|
+
* `PRESIGNED_REFRESH_MARGIN_MS` before their expiry (see
|
|
227
|
+
* `invalidateHostedImageUrl`), so repeated requests for the same image do not
|
|
228
|
+
* each pay a round trip.
|
|
229
|
+
*
|
|
230
|
+
* NEVER throws and never logs: returns `null` on any failure (missing token,
|
|
231
|
+
* non-2xx, network error, malformed body) so callers can fall back to their
|
|
232
|
+
* previous behaviour. Pass `onWarn` to see why.
|
|
233
|
+
*/
|
|
234
|
+
export async function resolveHostedImageUrl(options) {
|
|
235
|
+
const { backendUrl, token, url, fetchImpl, signal, timeoutMs, onWarn, } = options;
|
|
236
|
+
const explicitId = typeof options.imageId === "string" ? options.imageId.trim() : "";
|
|
237
|
+
const imageId = explicitId || (url ? extractHostedImageId(url) : null);
|
|
238
|
+
if (!backendUrl || !token || !imageId) {
|
|
239
|
+
onWarn?.(`Cannot resolve hosted image url: missing ${!backendUrl ? "backend url" : !token ? "machine token" : "image id"}`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// SECURITY: `url` is data that came from an image block, i.e. attacker
|
|
243
|
+
// influenceable. Refuse anything that is not a hosted reference on OUR
|
|
244
|
+
// backend origin — otherwise the machine JWT below would be sent to a
|
|
245
|
+
// third-party host (and a foreign `/generated-images/<id>` path would be
|
|
246
|
+
// swapped for a presigned url of one of our tenant's images).
|
|
247
|
+
if (url && !isHostedImageUrl(url, backendUrl)) {
|
|
248
|
+
onWarn?.(`Refusing to resolve a hosted image url for a foreign origin: ${url}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const key = cacheKey(backendUrl, imageId, token);
|
|
252
|
+
purgeHostedImageUrlCache();
|
|
253
|
+
const cached = hostedImageUrlCache.get(key);
|
|
254
|
+
if (cached && cached.expireRefreshAt > Date.now()) {
|
|
255
|
+
return {
|
|
256
|
+
url: cached.url,
|
|
257
|
+
expiresIn: Math.max(0, Math.floor((cached.expiresAt - Date.now()) / 1000)),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const doFetch = fetchImpl ?? ((...args) => fetch(...args));
|
|
261
|
+
try {
|
|
262
|
+
const response = await fetchWithTimeout(joinUrl(baseUrlOf(backendUrl), `/generated-images/${encodeURIComponent(imageId)}/url`), { method: "GET", headers: { Authorization: `Bearer ${token}` } }, { timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS, signal, fetchImpl: doFetch });
|
|
263
|
+
const json = await readJson(response);
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const { message } = errorMessageFrom(json, response.status);
|
|
266
|
+
onWarn?.(`Could not resolve hosted image url for ${imageId}: ${message}`);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const payload = isRecord(json) ? json : {};
|
|
270
|
+
const resolvedUrl = typeof payload.url === "string" ? payload.url.trim() : "";
|
|
271
|
+
if (!resolvedUrl) {
|
|
272
|
+
onWarn?.(`Backend response for hosted image ${imageId} is missing "url"`);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
// A missing/invalid `expiresIn` must not disable caching (that used to
|
|
276
|
+
// mint a fresh presigned url for every single request) — assume the
|
|
277
|
+
// backend default instead.
|
|
278
|
+
const expiresIn = Number(payload.expiresIn);
|
|
279
|
+
const ttlSeconds = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : DEFAULT_PRESIGNED_TTL_SECONDS;
|
|
280
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
281
|
+
hostedImageUrlCache.set(key, {
|
|
282
|
+
url: resolvedUrl,
|
|
283
|
+
expiresAt,
|
|
284
|
+
expireRefreshAt: Math.max(expiresAt - PRESIGNED_REFRESH_MARGIN_MS, Date.now()),
|
|
285
|
+
});
|
|
286
|
+
purgeHostedImageUrlCache();
|
|
287
|
+
return { url: resolvedUrl, expiresIn: ttlSeconds };
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
291
|
+
onWarn?.(`Could not resolve hosted image url for ${imageId}: ${message}`);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Thrown for every failure of the upload sequence (including presigned PUT
|
|
297
|
+
* failures). `status` is `undefined` for network-level errors.
|
|
298
|
+
*/
|
|
299
|
+
export class GeneratedImageUploadError extends Error {
|
|
300
|
+
step;
|
|
301
|
+
status;
|
|
302
|
+
type;
|
|
303
|
+
constructor(message, options) {
|
|
304
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
305
|
+
this.name = "GeneratedImageUploadError";
|
|
306
|
+
this.step = options.step;
|
|
307
|
+
this.status = options.status;
|
|
308
|
+
this.type = options.type;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function baseUrlOf(backendUrl) {
|
|
312
|
+
return backendUrl.replace(/\/+$/, "");
|
|
313
|
+
}
|
|
314
|
+
function joinUrl(base, path) {
|
|
315
|
+
return `${baseUrlOf(base)}${path.startsWith("/") ? path : `/${path}`}`;
|
|
316
|
+
}
|
|
317
|
+
function isRecord(value) {
|
|
318
|
+
return typeof value === "object" && value !== null;
|
|
319
|
+
}
|
|
320
|
+
function errorMessageFrom(body, status) {
|
|
321
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : undefined;
|
|
322
|
+
const message = typeof error?.message === "string" && error.message ? error.message : undefined;
|
|
323
|
+
const type = typeof error?.type === "string" && error.type ? error.type : undefined;
|
|
324
|
+
return { message: message ?? `HTTP ${status}`, type };
|
|
325
|
+
}
|
|
326
|
+
/** Parse a JSON body, tolerating empty/invalid bodies (presigned S3 replies are empty). */
|
|
327
|
+
async function readJson(response) {
|
|
328
|
+
const text = await response.text();
|
|
329
|
+
if (!text)
|
|
330
|
+
return undefined;
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(text);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function requireString(value, field, step) {
|
|
339
|
+
if (typeof value !== "string" || !value) {
|
|
340
|
+
throw new GeneratedImageUploadError(`Backend response is missing "${field}"`, { step });
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Run a single request with an optional timeout + external abort signal.
|
|
346
|
+
*
|
|
347
|
+
* The timeout never outlives the call and an already-aborted external signal
|
|
348
|
+
* aborts immediately.
|
|
349
|
+
*/
|
|
350
|
+
async function fetchWithTimeout(url, init, options) {
|
|
351
|
+
const controller = new AbortController();
|
|
352
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
353
|
+
const onAbort = () => controller.abort();
|
|
354
|
+
if (options.signal) {
|
|
355
|
+
if (options.signal.aborted)
|
|
356
|
+
controller.abort();
|
|
357
|
+
else
|
|
358
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
return await options.fetchImpl(url, { ...init, signal: controller.signal });
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function networkError(step, error) {
|
|
369
|
+
if (error instanceof GeneratedImageUploadError)
|
|
370
|
+
return error;
|
|
371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
372
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
373
|
+
return new GeneratedImageUploadError(aborted
|
|
374
|
+
? `Upload ${step} timed out or was cancelled (${message})`
|
|
375
|
+
: `Upload ${step} failed: ${message}`, { step, cause: error });
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Upload raw image bytes to backend storage and confirm them.
|
|
379
|
+
*
|
|
380
|
+
* @returns the image id, the backend-relative url and the confirmed byte size.
|
|
381
|
+
* @throws {GeneratedImageUploadError} on any HTTP or network failure, with a
|
|
382
|
+
* human-readable message taken from the backend `error.message` when present.
|
|
383
|
+
*/
|
|
384
|
+
export async function uploadImage(options) {
|
|
385
|
+
const { backendUrl, token, bytes, contentType, kind = "generated", width, height, fetchImpl, signal, timeoutMs, } = options;
|
|
386
|
+
if (!backendUrl) {
|
|
387
|
+
throw new GeneratedImageUploadError("Cannot upload image: no backend url configured", { step: "upload-url" });
|
|
388
|
+
}
|
|
389
|
+
if (!token) {
|
|
390
|
+
throw new GeneratedImageUploadError("Cannot upload image: not authenticated (missing machine token)", {
|
|
391
|
+
step: "upload-url",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const doFetch = fetchImpl ?? ((...args) => fetch(...args));
|
|
395
|
+
const base = baseUrlOf(backendUrl);
|
|
396
|
+
const authHeaders = { Authorization: `Bearer ${token}` };
|
|
397
|
+
// ── 1. mint an upload url ────────────────────────────────────────────────
|
|
398
|
+
let uploadUrlResponse;
|
|
399
|
+
try {
|
|
400
|
+
const body = {
|
|
401
|
+
contentType,
|
|
402
|
+
kind,
|
|
403
|
+
size: bytes.byteLength,
|
|
404
|
+
};
|
|
405
|
+
if (typeof width === "number" && Number.isFinite(width))
|
|
406
|
+
body.width = width;
|
|
407
|
+
if (typeof height === "number" && Number.isFinite(height))
|
|
408
|
+
body.height = height;
|
|
409
|
+
const response = await fetchWithTimeout(joinUrl(base, "/generated-images/upload-url"), {
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers: { ...authHeaders, "Content-Type": "application/json" },
|
|
412
|
+
body: JSON.stringify(body),
|
|
413
|
+
}, { timeoutMs, signal, fetchImpl: doFetch });
|
|
414
|
+
const json = await readJson(response);
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
const { message, type } = errorMessageFrom(json, response.status);
|
|
417
|
+
throw new GeneratedImageUploadError(`Could not mint image upload url: ${message}`, {
|
|
418
|
+
step: "upload-url",
|
|
419
|
+
status: response.status,
|
|
420
|
+
type,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const payload = isRecord(json) ? json : {};
|
|
424
|
+
uploadUrlResponse = {
|
|
425
|
+
imageId: requireString(payload.imageId, "imageId", "upload-url"),
|
|
426
|
+
uploadUrl: requireString(payload.uploadUrl, "uploadUrl", "upload-url"),
|
|
427
|
+
url: requireString(payload.url, "url", "upload-url"),
|
|
428
|
+
expiresIn: payload.expiresIn,
|
|
429
|
+
requiredHeaders: payload.requiredHeaders,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
throw networkError("upload-url", error);
|
|
434
|
+
}
|
|
435
|
+
// ── 2. PUT the bytes to the presigned url ────────────────────────────────
|
|
436
|
+
try {
|
|
437
|
+
const headers = { "Content-Type": contentType };
|
|
438
|
+
// The backend signs extra headers (e.g. `x-amz-acl: private`) into the
|
|
439
|
+
// presigned url — they MUST be sent verbatim or S3 rejects the PUT.
|
|
440
|
+
if (isRecord(uploadUrlResponse.requiredHeaders)) {
|
|
441
|
+
for (const [key, value] of Object.entries(uploadUrlResponse.requiredHeaders)) {
|
|
442
|
+
if (typeof value === "string")
|
|
443
|
+
headers[key] = value;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// Normalize to a standalone buffer so byteOffset/byteLength of a
|
|
447
|
+
// subarray (Buffer view) cannot leak extra bytes into the body.
|
|
448
|
+
const payload = new Uint8Array(bytes.byteLength);
|
|
449
|
+
payload.set(bytes);
|
|
450
|
+
const response = await fetchWithTimeout(uploadUrlResponse.uploadUrl, { method: "PUT", headers, body: payload }, { timeoutMs, signal, fetchImpl: doFetch });
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
const detail = await response.text().catch(() => "");
|
|
453
|
+
const snippet = detail.replace(/\s+/g, " ").trim().slice(0, 300);
|
|
454
|
+
throw new GeneratedImageUploadError(`Image upload to storage failed: HTTP ${response.status}${snippet ? ` (${snippet})` : ""}`, { step: "put", status: response.status });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
throw networkError("put", error);
|
|
459
|
+
}
|
|
460
|
+
// ── 3. confirm so the backend can serve the image ────────────────────────
|
|
461
|
+
try {
|
|
462
|
+
const response = await fetchWithTimeout(joinUrl(base, `/generated-images/${encodeURIComponent(uploadUrlResponse.imageId)}/confirm`), { method: "POST", headers: { ...authHeaders } }, { timeoutMs, signal, fetchImpl: doFetch });
|
|
463
|
+
const json = await readJson(response);
|
|
464
|
+
if (!response.ok) {
|
|
465
|
+
const { message, type } = errorMessageFrom(json, response.status);
|
|
466
|
+
throw new GeneratedImageUploadError(`Could not confirm uploaded image: ${message}`, {
|
|
467
|
+
step: "confirm",
|
|
468
|
+
status: response.status,
|
|
469
|
+
type,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
const payload = isRecord(json) ? json : {};
|
|
473
|
+
const confirmedSize = Number(payload.size);
|
|
474
|
+
return {
|
|
475
|
+
imageId: typeof payload.id === "string" && payload.id ? payload.id : uploadUrlResponse.imageId,
|
|
476
|
+
url: typeof payload.url === "string" && payload.url ? payload.url : uploadUrlResponse.url,
|
|
477
|
+
size: Number.isFinite(confirmedSize) && confirmedSize > 0 ? confirmedSize : bytes.byteLength,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
throw networkError("confirm", error);
|
|
482
|
+
}
|
|
483
|
+
}
|