@agent-native/core 0.168.5 → 0.168.7
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/corpus/README.md +1 -1
- package/corpus/templates/clips/actions/list-recordings.ts +14 -0
- package/corpus/templates/clips/app/components/library/recording-card.tsx +2 -2
- package/corpus/templates/clips/app/components/player/delete-recording-menu.tsx +20 -3
- package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +12 -5
- package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +147 -24
- package/corpus/templates/slides/server/handlers/uploads-chunked.ts +317 -0
- package/corpus/templates/slides/server/handlers/uploads.ts +3 -4
- package/corpus/templates/slides/server/lib/chunked-upload-session.ts +54 -0
- package/corpus/templates/slides/server/routes/api/uploads-chunked/[sessionId]/chunk.post.ts +1 -0
- package/corpus/templates/slides/server/routes/api/uploads-chunked/start.post.ts +1 -0
- package/corpus/templates/slides/shared/upload-types.ts +1 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/file-upload/builder.js +24 -0
- package/dist/file-upload/index.d.ts +2 -2
- package/dist/file-upload/index.js +1 -1
- package/dist/file-upload/registry.d.ts +2 -1
- package/dist/file-upload/registry.js +8 -0
- package/dist/file-upload/s3.js +59 -1
- package/dist/file-upload/types.d.ts +6 -0
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/private-blob/registry.d.ts +0 -5
- package/dist/private-blob/registry.js +25 -18
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/ssr-handler.js +19 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "drizzle-orm";
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
|
|
17
|
+
import { effectiveDuration, parseEdits } from "../app/lib/timestamp-mapping.js";
|
|
17
18
|
import { getDb, schema } from "../server/db/index.js";
|
|
18
19
|
import {
|
|
19
20
|
agentRecordingAccessFilter,
|
|
@@ -303,6 +304,9 @@ export default defineAction({
|
|
|
303
304
|
thumbnailUrl: schema.recordings.thumbnailUrl,
|
|
304
305
|
animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl,
|
|
305
306
|
durationMs: schema.recordings.durationMs,
|
|
307
|
+
// Needed to derive the edited-length badge below; dropped before the
|
|
308
|
+
// response is built so the raw edits blob never reaches the client.
|
|
309
|
+
editsJson: schema.recordings.editsJson,
|
|
306
310
|
status: schema.recordings.status,
|
|
307
311
|
uploadProgress: schema.recordings.uploadProgress,
|
|
308
312
|
failureReason: schema.recordings.failureReason,
|
|
@@ -417,7 +421,17 @@ export default defineAction({
|
|
|
417
421
|
description: r.description,
|
|
418
422
|
thumbnailUrl: r.thumbnailUrl,
|
|
419
423
|
animatedThumbnailUrl: r.animatedThumbnailUrl,
|
|
424
|
+
// Raw source length. StitchManager sums this across queued
|
|
425
|
+
// recordings to size the concatenated export, which always
|
|
426
|
+
// includes each source's full untrimmed media — trims are applied
|
|
427
|
+
// at export/playback time, not by dropping bytes from the source.
|
|
420
428
|
durationMs: r.durationMs,
|
|
429
|
+
// Edited length, not the original recorded length — matches what
|
|
430
|
+
// the clip page itself shows once trims/cuts are applied.
|
|
431
|
+
effectiveDurationMs: effectiveDuration(
|
|
432
|
+
r.durationMs,
|
|
433
|
+
parseEdits(r.editsJson),
|
|
434
|
+
),
|
|
421
435
|
status: r.status,
|
|
422
436
|
uploadProgress: r.uploadProgress,
|
|
423
437
|
failureReason: r.failureReason,
|
|
@@ -98,8 +98,8 @@ export function RecordingCard({
|
|
|
98
98
|
const pendingTrashRef = useRef(false);
|
|
99
99
|
|
|
100
100
|
const duration = useMemo(
|
|
101
|
-
() => formatDuration(recording.
|
|
102
|
-
[recording.
|
|
101
|
+
() => formatDuration(recording.effectiveDurationMs),
|
|
102
|
+
[recording.effectiveDurationMs],
|
|
103
103
|
);
|
|
104
104
|
const relative = useMemo(() => {
|
|
105
105
|
const date = new Date(recording.createdAt);
|
|
@@ -51,6 +51,7 @@ export function RecordingOptionsMenu({
|
|
|
51
51
|
const [open, setOpen] = useState(false);
|
|
52
52
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
53
53
|
const deletedWhileOpenRef = useRef(false);
|
|
54
|
+
const pendingDeleteConfirmRef = useRef(false);
|
|
54
55
|
const showDownload = canDownload && Boolean(onDownload);
|
|
55
56
|
const showDelete = canDelete;
|
|
56
57
|
const trashRecording = useActionMutation<any, { id: string }>(
|
|
@@ -99,7 +100,22 @@ export function RecordingOptionsMenu({
|
|
|
99
100
|
<IconDotsVertical className="h-4 w-4" />
|
|
100
101
|
</Button>
|
|
101
102
|
</DropdownMenuTrigger>
|
|
102
|
-
<DropdownMenuContent
|
|
103
|
+
<DropdownMenuContent
|
|
104
|
+
align="end"
|
|
105
|
+
className="w-44"
|
|
106
|
+
onCloseAutoFocus={(event) => {
|
|
107
|
+
// Opening the AlertDialog while this menu is still tearing down
|
|
108
|
+
// leaves `pointer-events: none` stuck on <body>: two dismissable
|
|
109
|
+
// layers overlap and the survivor never restores the style. Wait
|
|
110
|
+
// for the menu to finish closing, and keep focus off the trigger
|
|
111
|
+
// so the dialog owns it.
|
|
112
|
+
if (pendingDeleteConfirmRef.current) {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
pendingDeleteConfirmRef.current = false;
|
|
115
|
+
setOpen(true);
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
103
119
|
{showDownload ? (
|
|
104
120
|
<DropdownMenuItem
|
|
105
121
|
onSelect={handleDownload}
|
|
@@ -116,7 +132,8 @@ export function RecordingOptionsMenu({
|
|
|
116
132
|
<DropdownMenuItem
|
|
117
133
|
onSelect={(event) => {
|
|
118
134
|
event.preventDefault();
|
|
119
|
-
|
|
135
|
+
pendingDeleteConfirmRef.current = true;
|
|
136
|
+
setMenuOpen(false);
|
|
120
137
|
}}
|
|
121
138
|
className="text-destructive focus:text-destructive"
|
|
122
139
|
>
|
|
@@ -132,7 +149,7 @@ export function RecordingOptionsMenu({
|
|
|
132
149
|
if (!deletedWhileOpenRef.current) return;
|
|
133
150
|
deletedWhileOpenRef.current = false;
|
|
134
151
|
event.preventDefault();
|
|
135
|
-
|
|
152
|
+
onDeleted?.();
|
|
136
153
|
}}
|
|
137
154
|
>
|
|
138
155
|
<AlertDialogHeader>
|
|
@@ -15,6 +15,7 @@ export interface RecordingSummary {
|
|
|
15
15
|
thumbnailUrl: string | null;
|
|
16
16
|
animatedThumbnailUrl: string | null;
|
|
17
17
|
durationMs: number;
|
|
18
|
+
effectiveDurationMs: number;
|
|
18
19
|
status: "uploading" | "processing" | "ready" | "failed";
|
|
19
20
|
uploadProgress?: number;
|
|
20
21
|
failureReason?: string | null;
|
|
@@ -247,9 +247,8 @@ function nativeSaveFailureMessage(reason: string | null | undefined): string {
|
|
|
247
247
|
return "The desktop recorder finished and saved a local copy, but Clips could not upload it. You can retry from the Clips menu without recording again.";
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
export function
|
|
250
|
+
export function BackButton({ onBack }: { onBack: () => void }) {
|
|
251
251
|
const t = useT();
|
|
252
|
-
const navigate = useNavigate();
|
|
253
252
|
|
|
254
253
|
return (
|
|
255
254
|
<Tooltip>
|
|
@@ -258,7 +257,7 @@ export function BackToLibraryButton() {
|
|
|
258
257
|
variant="ghost"
|
|
259
258
|
size="icon"
|
|
260
259
|
className="shrink-0"
|
|
261
|
-
onClick={
|
|
260
|
+
onClick={onBack}
|
|
262
261
|
aria-label={t("recordingPage.backToLibrary")}
|
|
263
262
|
>
|
|
264
263
|
<IconArrowLeft className="h-4 w-4 rtl:-scale-x-100" />
|
|
@@ -1053,7 +1052,9 @@ export default function RecordingPage() {
|
|
|
1053
1052
|
return (
|
|
1054
1053
|
<div className="flex min-h-screen w-full flex-col bg-background">
|
|
1055
1054
|
<header className="flex min-w-0 shrink-0 items-center gap-2 border-b border-border px-3 py-2 sm:px-4 sm:py-3">
|
|
1056
|
-
<
|
|
1055
|
+
<BackButton
|
|
1056
|
+
onBack={() => navigate("/library", { replace: true })}
|
|
1057
|
+
/>
|
|
1057
1058
|
<div className="min-w-0 flex-1">
|
|
1058
1059
|
<p className="truncate text-sm font-medium">{visibleTitle}</p>
|
|
1059
1060
|
<p className="truncate text-xs text-muted-foreground">
|
|
@@ -1369,7 +1370,13 @@ export default function RecordingPage() {
|
|
|
1369
1370
|
{/* Main video column */}
|
|
1370
1371
|
<div className="flex w-full min-w-0 flex-col xl:flex-1">
|
|
1371
1372
|
<header className="flex min-w-0 shrink-0 items-center gap-2 border-b border-border px-3 py-2 sm:px-4 sm:py-3">
|
|
1372
|
-
<
|
|
1373
|
+
<BackButton
|
|
1374
|
+
onBack={
|
|
1375
|
+
editing
|
|
1376
|
+
? () => setEditing(false)
|
|
1377
|
+
: () => navigate("/library", { replace: true })
|
|
1378
|
+
}
|
|
1379
|
+
/>
|
|
1373
1380
|
<div className="flex-1 min-w-0">
|
|
1374
1381
|
<EditableRecordingTitle
|
|
1375
1382
|
recordingId={recording.id}
|
|
@@ -14,7 +14,10 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|
|
14
14
|
import { createPortal } from "react-dom";
|
|
15
15
|
import { toast } from "sonner";
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
MAX_REFERENCE_FILE_BYTES,
|
|
19
|
+
MAX_REFERENCE_FILES,
|
|
20
|
+
} from "../../../shared/upload-types";
|
|
18
21
|
import { Button } from "../ui/button";
|
|
19
22
|
import { Input } from "../ui/input";
|
|
20
23
|
import { GoogleDocImportHint } from "./GoogleDocImportHint";
|
|
@@ -29,39 +32,159 @@ export interface UploadedFile {
|
|
|
29
32
|
size: number;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
|
|
35
|
+
// Netlify functions cap request bodies well under what a real PPTX/PDF
|
|
36
|
+
// needs, so any file above this size streams through the chunked upload
|
|
37
|
+
// endpoints (sub-4 MB slices, reassembled server-side) instead of one
|
|
38
|
+
// multipart POST.
|
|
39
|
+
const CHUNK_UPLOAD_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
|
40
|
+
const CHUNK_SIZE_BYTES = 4 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
async function readUploadJson(response: Response): Promise<unknown> {
|
|
43
|
+
try {
|
|
44
|
+
return await response.json();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(`Upload returned invalid JSON (${response.status})`, {
|
|
47
|
+
cause: error,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractErrorMessage(data: unknown): string | null {
|
|
53
|
+
if (
|
|
54
|
+
data &&
|
|
55
|
+
typeof data === "object" &&
|
|
56
|
+
"error" in data &&
|
|
57
|
+
typeof (data as { error: unknown }).error === "string" &&
|
|
58
|
+
(data as { error: string }).error.trim()
|
|
59
|
+
) {
|
|
60
|
+
return (data as { error: string }).error;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function uploadFilesMultipart(files: File[]): Promise<UploadedFile[]> {
|
|
36
66
|
const formData = new FormData();
|
|
37
67
|
files.forEach((file) => formData.append("files", file));
|
|
38
|
-
ensureEmbedAuthFetchInterceptor();
|
|
39
68
|
const response = await fetch(`${appBasePath()}/api/uploads`, {
|
|
40
69
|
method: "POST",
|
|
41
70
|
body: formData,
|
|
42
71
|
credentials: "include",
|
|
43
72
|
});
|
|
73
|
+
const data = await readUploadJson(response);
|
|
44
74
|
if (!response.ok) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
throw new Error(
|
|
76
|
+
extractErrorMessage(data) || `Upload failed (${response.status})`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(data)) {
|
|
80
|
+
throw new Error("Upload failed: invalid response");
|
|
81
|
+
}
|
|
82
|
+
return data as UploadedFile[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function uploadFileChunked(file: File): Promise<UploadedFile> {
|
|
86
|
+
const startResponse = await fetch(
|
|
87
|
+
`${appBasePath()}/api/uploads-chunked/start`,
|
|
88
|
+
{
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
credentials: "include",
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
filename: file.name,
|
|
94
|
+
mimetype: file.type || "application/octet-stream",
|
|
95
|
+
declaredSize: file.size,
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const startData = await readUploadJson(startResponse);
|
|
100
|
+
if (!startResponse.ok) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
extractErrorMessage(startData) ||
|
|
103
|
+
`Upload failed (${startResponse.status})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
startData &&
|
|
108
|
+
typeof startData === "object" &&
|
|
109
|
+
(startData as { uploadMode?: unknown }).uploadMode === "multipart"
|
|
110
|
+
) {
|
|
111
|
+
const [uploaded] = await uploadFilesMultipart([file]);
|
|
112
|
+
if (!uploaded) throw new Error("Upload failed: no file returned");
|
|
113
|
+
return uploaded;
|
|
114
|
+
}
|
|
115
|
+
const sessionId =
|
|
116
|
+
startData && typeof startData === "object"
|
|
117
|
+
? (startData as { sessionId?: unknown }).sessionId
|
|
118
|
+
: undefined;
|
|
119
|
+
if (typeof sessionId !== "string" || !sessionId) {
|
|
120
|
+
throw new Error("Upload failed: session ID missing");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE_BYTES));
|
|
124
|
+
for (let index = 0; index < totalChunks; index++) {
|
|
125
|
+
const start = index * CHUNK_SIZE_BYTES;
|
|
126
|
+
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
|
|
127
|
+
const isFinal = index === totalChunks - 1;
|
|
128
|
+
const chunkResponse = await fetch(
|
|
129
|
+
`${appBasePath()}/api/uploads-chunked/${sessionId}/chunk?index=${index}&isFinal=${
|
|
130
|
+
isFinal ? "1" : "0"
|
|
131
|
+
}`,
|
|
132
|
+
{
|
|
133
|
+
method: "POST",
|
|
134
|
+
credentials: "include",
|
|
135
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
136
|
+
body: file.slice(start, end),
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
const chunkData = await readUploadJson(chunkResponse);
|
|
140
|
+
if (!chunkResponse.ok) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
extractErrorMessage(chunkData) ||
|
|
143
|
+
`Upload failed (${chunkResponse.status})`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (isFinal) {
|
|
147
|
+
const result = Array.isArray(chunkData)
|
|
148
|
+
? (chunkData[0] as UploadedFile)
|
|
149
|
+
: undefined;
|
|
150
|
+
if (!result) throw new Error("Upload failed: no file returned");
|
|
151
|
+
return result;
|
|
61
152
|
}
|
|
62
|
-
throw new Error(message);
|
|
63
153
|
}
|
|
64
|
-
|
|
154
|
+
throw new Error("Upload failed: no final chunk response");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function uploadPromptFiles(
|
|
158
|
+
files: File[],
|
|
159
|
+
): Promise<UploadedFile[]> {
|
|
160
|
+
if (files.length === 0) return [];
|
|
161
|
+
if (files.length > MAX_REFERENCE_FILES) {
|
|
162
|
+
throw new Error(`Too many files (max ${MAX_REFERENCE_FILES})`);
|
|
163
|
+
}
|
|
164
|
+
ensureEmbedAuthFetchInterceptor();
|
|
165
|
+
const smallIndices = files.flatMap((file, index) =>
|
|
166
|
+
file.size <= CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
|
|
167
|
+
);
|
|
168
|
+
const largeIndices = files.flatMap((file, index) =>
|
|
169
|
+
file.size > CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
|
|
170
|
+
);
|
|
171
|
+
const [smallUploads, largeUploads] = await Promise.all([
|
|
172
|
+
smallIndices.length > 0
|
|
173
|
+
? uploadFilesMultipart(smallIndices.map((index) => files[index]))
|
|
174
|
+
: [],
|
|
175
|
+
Promise.all(largeIndices.map((index) => uploadFileChunked(files[index]))),
|
|
176
|
+
]);
|
|
177
|
+
if (smallUploads.length !== smallIndices.length) {
|
|
178
|
+
throw new Error("Upload failed: response file count did not match request");
|
|
179
|
+
}
|
|
180
|
+
const uploads = new Array<UploadedFile>(files.length);
|
|
181
|
+
smallIndices.forEach((fileIndex, resultIndex) => {
|
|
182
|
+
uploads[fileIndex] = smallUploads[resultIndex];
|
|
183
|
+
});
|
|
184
|
+
largeIndices.forEach((fileIndex, resultIndex) => {
|
|
185
|
+
uploads[fileIndex] = largeUploads[resultIndex];
|
|
186
|
+
});
|
|
187
|
+
return uploads;
|
|
65
188
|
}
|
|
66
189
|
|
|
67
190
|
/**
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deletePrivateBlob,
|
|
3
|
+
putPrivateBlob,
|
|
4
|
+
readPrivateBlob,
|
|
5
|
+
} from "@agent-native/core/private-blob";
|
|
6
|
+
import {
|
|
7
|
+
defineEventHandler,
|
|
8
|
+
getHeader,
|
|
9
|
+
getQuery,
|
|
10
|
+
getRouterParam,
|
|
11
|
+
readBody,
|
|
12
|
+
readRawBody,
|
|
13
|
+
setResponseStatus,
|
|
14
|
+
} from "h3";
|
|
15
|
+
import { nanoid } from "nanoid";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createChunkedUploadSession,
|
|
19
|
+
deleteChunkedUploadSession,
|
|
20
|
+
getChunkedUploadSession,
|
|
21
|
+
listChunkedUploadSessions,
|
|
22
|
+
type ChunkedUploadSession,
|
|
23
|
+
} from "../lib/chunked-upload-session.js";
|
|
24
|
+
import { isHostedSlidesRuntime } from "../lib/tenant-files.js";
|
|
25
|
+
import {
|
|
26
|
+
resolveSlidesRequestAuth,
|
|
27
|
+
withSlidesRequestContext,
|
|
28
|
+
} from "./request-auth-context.js";
|
|
29
|
+
import { maxReferenceFileBytes, saveUploadedReferenceFile } from "./uploads.js";
|
|
30
|
+
|
|
31
|
+
const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
const MAX_CHUNKS = 128;
|
|
33
|
+
const SESSION_TTL_MS = 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
interface StartBody {
|
|
36
|
+
filename?: unknown;
|
|
37
|
+
mimetype?: unknown;
|
|
38
|
+
declaredSize?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function deleteChunk(handle: ChunkedUploadSession["chunks"][string]) {
|
|
42
|
+
return (await deletePrivateBlob(handle)).deleted;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function cleanupChunks(session: ChunkedUploadSession): Promise<boolean> {
|
|
46
|
+
const results = await Promise.all(
|
|
47
|
+
Object.values(session.chunks).map(deleteChunk),
|
|
48
|
+
);
|
|
49
|
+
return results.every(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function discardSession(
|
|
53
|
+
sessionId: string,
|
|
54
|
+
session: ChunkedUploadSession,
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const cleaned = await cleanupChunks(session);
|
|
57
|
+
if (cleaned) await deleteChunkedUploadSession(sessionId);
|
|
58
|
+
return cleaned;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function reapExpiredChunkedUploads(): Promise<void> {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const sessions = await listChunkedUploadSessions();
|
|
64
|
+
await Promise.all(
|
|
65
|
+
sessions.map(async ({ sessionId, session }) => {
|
|
66
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
67
|
+
if (Number.isFinite(expiresAt) && expiresAt > now) return;
|
|
68
|
+
try {
|
|
69
|
+
const cleaned = await discardSession(sessionId, session);
|
|
70
|
+
if (!cleaned) {
|
|
71
|
+
console.warn("[slides-upload] expired session cleanup incomplete", {
|
|
72
|
+
sessionId,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn("[slides-upload] expired session cleanup failed", {
|
|
77
|
+
sessionId,
|
|
78
|
+
error: error instanceof Error ? error.message : String(error),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function cleanupCommittedSession(
|
|
86
|
+
sessionId: string,
|
|
87
|
+
session: ChunkedUploadSession,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
const cleaned = await discardSession(sessionId, session);
|
|
91
|
+
if (!cleaned) {
|
|
92
|
+
console.warn("[slides-upload] committed session cleanup incomplete", {
|
|
93
|
+
sessionId,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.warn("[slides-upload] committed session cleanup failed", {
|
|
98
|
+
sessionId,
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const startChunkedUpload = defineEventHandler(async (event) => {
|
|
105
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
106
|
+
if (!auth.ok) {
|
|
107
|
+
setResponseStatus(event, auth.statusCode);
|
|
108
|
+
return { error: auth.error };
|
|
109
|
+
}
|
|
110
|
+
const authContext = auth.context;
|
|
111
|
+
if (!authContext.email) {
|
|
112
|
+
setResponseStatus(event, 401);
|
|
113
|
+
return { error: "Unauthorized" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return withSlidesRequestContext(
|
|
117
|
+
event,
|
|
118
|
+
async () => {
|
|
119
|
+
if (!isHostedSlidesRuntime()) {
|
|
120
|
+
return { uploadMode: "multipart" as const };
|
|
121
|
+
}
|
|
122
|
+
await reapExpiredChunkedUploads();
|
|
123
|
+
const body = (await readBody(event).catch(
|
|
124
|
+
() => null,
|
|
125
|
+
)) as StartBody | null;
|
|
126
|
+
const filename =
|
|
127
|
+
typeof body?.filename === "string" ? body.filename.trim() : "";
|
|
128
|
+
const mimetype =
|
|
129
|
+
typeof body?.mimetype === "string" && body.mimetype.trim()
|
|
130
|
+
? body.mimetype.trim()
|
|
131
|
+
: "application/octet-stream";
|
|
132
|
+
const declaredSize = Number(body?.declaredSize);
|
|
133
|
+
if (!filename) {
|
|
134
|
+
setResponseStatus(event, 400);
|
|
135
|
+
return { error: "filename is required" };
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isSafeInteger(declaredSize) || declaredSize <= 0) {
|
|
138
|
+
setResponseStatus(event, 400);
|
|
139
|
+
return { error: "declaredSize must be a positive integer" };
|
|
140
|
+
}
|
|
141
|
+
const limit = maxReferenceFileBytes(filename);
|
|
142
|
+
if (declaredSize > limit) {
|
|
143
|
+
setResponseStatus(event, 413);
|
|
144
|
+
return {
|
|
145
|
+
error: `File too large (max ${Math.round(limit / 1024 / 1024)} MB)`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sessionId = nanoid();
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
await createChunkedUploadSession(sessionId, {
|
|
152
|
+
filename,
|
|
153
|
+
mimeType: mimetype,
|
|
154
|
+
declaredSize,
|
|
155
|
+
chunks: {},
|
|
156
|
+
chunkSizes: {},
|
|
157
|
+
createdAt: new Date(now).toISOString(),
|
|
158
|
+
expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
|
|
159
|
+
});
|
|
160
|
+
return { sessionId, maxChunkBytes: MAX_CHUNK_BYTES };
|
|
161
|
+
},
|
|
162
|
+
authContext,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const uploadChunkedChunk = defineEventHandler(async (event) => {
|
|
167
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
168
|
+
if (!auth.ok) {
|
|
169
|
+
setResponseStatus(event, auth.statusCode);
|
|
170
|
+
return { error: auth.error };
|
|
171
|
+
}
|
|
172
|
+
const authContext = auth.context;
|
|
173
|
+
const email = authContext.email;
|
|
174
|
+
if (!email) {
|
|
175
|
+
setResponseStatus(event, 401);
|
|
176
|
+
return { error: "Unauthorized" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return withSlidesRequestContext(
|
|
180
|
+
event,
|
|
181
|
+
async ({ orgId }) => {
|
|
182
|
+
const sessionId = getRouterParam(event, "sessionId");
|
|
183
|
+
if (!sessionId) {
|
|
184
|
+
setResponseStatus(event, 400);
|
|
185
|
+
return { error: "Missing sessionId" };
|
|
186
|
+
}
|
|
187
|
+
const session = await getChunkedUploadSession(sessionId);
|
|
188
|
+
if (!session) {
|
|
189
|
+
setResponseStatus(event, 404);
|
|
190
|
+
return { error: "Upload session not found or expired" };
|
|
191
|
+
}
|
|
192
|
+
if (Date.parse(session.expiresAt) <= Date.now()) {
|
|
193
|
+
await discardSession(sessionId, session);
|
|
194
|
+
setResponseStatus(event, 410);
|
|
195
|
+
return { error: "Upload session expired" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const query = getQuery(event);
|
|
199
|
+
const index = Number(query.index ?? 0);
|
|
200
|
+
const isFinal = query.isFinal === "1" || query.isFinal === "true";
|
|
201
|
+
if (!Number.isInteger(index) || index < 0 || index >= MAX_CHUNKS) {
|
|
202
|
+
setResponseStatus(event, 400);
|
|
203
|
+
return { error: "Invalid chunk index" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const contentLengthHeader = getHeader(event, "content-length");
|
|
207
|
+
if (!contentLengthHeader || !/^\d+$/.test(contentLengthHeader)) {
|
|
208
|
+
setResponseStatus(event, 411);
|
|
209
|
+
return { error: "Valid Content-Length header required" };
|
|
210
|
+
}
|
|
211
|
+
const contentLength = Number(contentLengthHeader);
|
|
212
|
+
if (contentLength <= 0) {
|
|
213
|
+
setResponseStatus(event, 400);
|
|
214
|
+
return { error: "Empty chunk body" };
|
|
215
|
+
}
|
|
216
|
+
if (contentLength > MAX_CHUNK_BYTES) {
|
|
217
|
+
setResponseStatus(event, 413);
|
|
218
|
+
return { error: "Chunk too large" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const chunkKey = String(index);
|
|
222
|
+
const previousSize = session.chunkSizes[chunkKey] ?? 0;
|
|
223
|
+
const receivedBefore = Object.values(session.chunkSizes).reduce(
|
|
224
|
+
(total, size) => total + size,
|
|
225
|
+
0,
|
|
226
|
+
);
|
|
227
|
+
const nextSize = receivedBefore - previousSize + contentLength;
|
|
228
|
+
const fileLimit = maxReferenceFileBytes(session.filename);
|
|
229
|
+
if (nextSize > session.declaredSize || nextSize > fileLimit) {
|
|
230
|
+
await discardSession(sessionId, session);
|
|
231
|
+
setResponseStatus(event, 413);
|
|
232
|
+
return { error: "Uploaded bytes exceed the declared file size" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const raw = await readRawBody(event, false);
|
|
236
|
+
const bytes = raw ?? new Uint8Array(0);
|
|
237
|
+
if (bytes.byteLength !== contentLength) {
|
|
238
|
+
setResponseStatus(event, 400);
|
|
239
|
+
return { error: "Chunk size does not match Content-Length" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const previousHandle = session.chunks[chunkKey];
|
|
243
|
+
if (previousHandle && !(await deleteChunk(previousHandle))) {
|
|
244
|
+
setResponseStatus(event, 503);
|
|
245
|
+
return { error: "Could not replace the previously uploaded chunk" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const handle = await putPrivateBlob({
|
|
249
|
+
data: bytes,
|
|
250
|
+
filename: `${sessionId}-${index}`,
|
|
251
|
+
mimeType: "application/octet-stream",
|
|
252
|
+
ownerEmail: email,
|
|
253
|
+
});
|
|
254
|
+
if (!handle) {
|
|
255
|
+
setResponseStatus(event, 503);
|
|
256
|
+
return { error: "Upload storage is not available" };
|
|
257
|
+
}
|
|
258
|
+
session.chunks[chunkKey] = handle;
|
|
259
|
+
session.chunkSizes[chunkKey] = bytes.byteLength;
|
|
260
|
+
await createChunkedUploadSession(sessionId, session);
|
|
261
|
+
|
|
262
|
+
if (!isFinal) return { ok: true };
|
|
263
|
+
|
|
264
|
+
const orderedIndices = Object.keys(session.chunks)
|
|
265
|
+
.map(Number)
|
|
266
|
+
.sort((a, b) => a - b);
|
|
267
|
+
const missing = orderedIndices.some((value, i) => value !== i);
|
|
268
|
+
const receivedSize = Object.values(session.chunkSizes).reduce(
|
|
269
|
+
(total, size) => total + size,
|
|
270
|
+
0,
|
|
271
|
+
);
|
|
272
|
+
if (
|
|
273
|
+
missing ||
|
|
274
|
+
orderedIndices.length === 0 ||
|
|
275
|
+
receivedSize !== session.declaredSize
|
|
276
|
+
) {
|
|
277
|
+
await discardSession(sessionId, session);
|
|
278
|
+
setResponseStatus(event, 400);
|
|
279
|
+
return { error: "Upload is incomplete or has an invalid size" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let result;
|
|
283
|
+
try {
|
|
284
|
+
const parts = await Promise.all(
|
|
285
|
+
orderedIndices.map(async (chunkIndex) => {
|
|
286
|
+
const chunkHandle = session.chunks[String(chunkIndex)];
|
|
287
|
+
const read = await readPrivateBlob(chunkHandle);
|
|
288
|
+
return Buffer.from(read.data);
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
const combined = Buffer.concat(parts);
|
|
292
|
+
if (combined.byteLength !== session.declaredSize) {
|
|
293
|
+
throw new Error("Assembled upload size does not match declaredSize");
|
|
294
|
+
}
|
|
295
|
+
result = await saveUploadedReferenceFile({
|
|
296
|
+
email,
|
|
297
|
+
orgId,
|
|
298
|
+
originalName: session.filename,
|
|
299
|
+
data: combined,
|
|
300
|
+
type: session.mimeType,
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
await discardSession(sessionId, session);
|
|
304
|
+
const statusCode =
|
|
305
|
+
typeof (err as { statusCode?: unknown })?.statusCode === "number"
|
|
306
|
+
? (err as { statusCode: number }).statusCode
|
|
307
|
+
: 400;
|
|
308
|
+
setResponseStatus(event, statusCode);
|
|
309
|
+
return { error: err instanceof Error ? err.message : "Invalid upload" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await cleanupCommittedSession(sessionId, session);
|
|
313
|
+
return [result];
|
|
314
|
+
},
|
|
315
|
+
authContext,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
@@ -11,6 +11,7 @@ import { nanoid } from "nanoid";
|
|
|
11
11
|
import {
|
|
12
12
|
MAX_FIG_REFERENCE_FILE_BYTES,
|
|
13
13
|
MAX_REFERENCE_FILE_BYTES,
|
|
14
|
+
MAX_REFERENCE_FILES,
|
|
14
15
|
SLIDES_REFERENCE_FILE_ERROR_LABEL,
|
|
15
16
|
isSlidesReferenceFileExtension,
|
|
16
17
|
} from "../../shared/upload-types.js";
|
|
@@ -237,11 +238,9 @@ export const uploadFiles = defineEventHandler(async (event) => {
|
|
|
237
238
|
return { error: "No files uploaded" };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (fileParts.length > MAX_FILES) {
|
|
241
|
+
if (fileParts.length > MAX_REFERENCE_FILES) {
|
|
243
242
|
setResponseStatus(event, 413);
|
|
244
|
-
return { error: `Too many files (max ${
|
|
243
|
+
return { error: `Too many files (max ${MAX_REFERENCE_FILES})` };
|
|
245
244
|
}
|
|
246
245
|
|
|
247
246
|
const oversized = fileParts.find(
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deleteAppState,
|
|
3
|
+
listAppState,
|
|
4
|
+
readAppState,
|
|
5
|
+
writeAppState,
|
|
6
|
+
} from "@agent-native/core/application-state";
|
|
7
|
+
import type { PrivateBlobHandle } from "@agent-native/core/private-blob";
|
|
8
|
+
|
|
9
|
+
export interface ChunkedUploadSession {
|
|
10
|
+
filename: string;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
declaredSize: number;
|
|
13
|
+
chunks: Record<string, PrivateBlobHandle>;
|
|
14
|
+
chunkSizes: Record<string, number>;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PREFIX = "slides-upload-chunks-";
|
|
20
|
+
const key = (sessionId: string) => `${PREFIX}${sessionId}`;
|
|
21
|
+
|
|
22
|
+
export async function createChunkedUploadSession(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
session: ChunkedUploadSession,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await writeAppState(
|
|
27
|
+
key(sessionId),
|
|
28
|
+
session as unknown as Record<string, unknown>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getChunkedUploadSession(
|
|
33
|
+
sessionId: string,
|
|
34
|
+
): Promise<ChunkedUploadSession | null> {
|
|
35
|
+
const raw = await readAppState(key(sessionId));
|
|
36
|
+
if (!raw || typeof raw !== "object") return null;
|
|
37
|
+
return raw as unknown as ChunkedUploadSession;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listChunkedUploadSessions(): Promise<
|
|
41
|
+
Array<{ sessionId: string; session: ChunkedUploadSession }>
|
|
42
|
+
> {
|
|
43
|
+
const entries = await listAppState(PREFIX);
|
|
44
|
+
return entries.map(({ key: entryKey, value }) => ({
|
|
45
|
+
sessionId: entryKey.slice(PREFIX.length),
|
|
46
|
+
session: value as unknown as ChunkedUploadSession,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function deleteChunkedUploadSession(
|
|
51
|
+
sessionId: string,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
await deleteAppState(key(sessionId));
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { uploadChunkedChunk as default } from "../../../../handlers/uploads-chunked";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { startChunkedUpload as default } from "../../../handlers/uploads-chunked";
|
|
@@ -25,6 +25,7 @@ export const SLIDES_REFERENCE_FILE_LABEL =
|
|
|
25
25
|
export const SLIDES_REFERENCE_FILE_ERROR_LABEL =
|
|
26
26
|
"pptx, docx, pdf, fig, text, Markdown, JSON, CSV, and images including SVG";
|
|
27
27
|
|
|
28
|
+
export const MAX_REFERENCE_FILES = 20;
|
|
28
29
|
export const MAX_REFERENCE_FILE_BYTES = 50 * 1024 * 1024;
|
|
29
30
|
export const MAX_FIG_REFERENCE_FILE_BYTES = 200 * 1024 * 1024;
|
|
30
31
|
|
|
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
|
|
|
62
62
|
error: string;
|
|
63
63
|
states?: undefined;
|
|
64
64
|
} | {
|
|
65
|
-
error?: undefined;
|
|
66
65
|
states: {
|
|
67
66
|
clientId: number;
|
|
68
67
|
state: string;
|
|
69
68
|
}[];
|
|
69
|
+
error?: undefined;
|
|
70
70
|
}>>;
|
|
71
71
|
/**
|
|
72
72
|
* GET /_agent-native/collab/:docId/users
|
|
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
|
|
|
77
77
|
error: string;
|
|
78
78
|
users?: undefined;
|
|
79
79
|
} | {
|
|
80
|
-
error?: undefined;
|
|
81
80
|
users: {
|
|
82
81
|
clientId: number;
|
|
83
82
|
lastSeen: number;
|
|
84
83
|
}[];
|
|
84
|
+
error?: undefined;
|
|
85
85
|
}>>;
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
ok?: undefined;
|
|
17
16
|
error: string;
|
|
17
|
+
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -17,11 +17,11 @@ declare const _default: import("../../action.js").ActionDefinition<{
|
|
|
17
17
|
id?: undefined;
|
|
18
18
|
provider?: undefined;
|
|
19
19
|
} | {
|
|
20
|
-
error?: undefined;
|
|
21
20
|
configured?: undefined;
|
|
22
21
|
connectPath?: undefined;
|
|
23
22
|
url: string;
|
|
24
23
|
id: string;
|
|
25
24
|
provider: string;
|
|
25
|
+
error?: undefined;
|
|
26
26
|
}>;
|
|
27
27
|
export default _default;
|
|
@@ -199,6 +199,30 @@ export const builderFileUploadProvider = {
|
|
|
199
199
|
console.log(`[builder-upload] done: ${json.url}`);
|
|
200
200
|
return { url: json.url, id: json.id, provider: "builder" };
|
|
201
201
|
},
|
|
202
|
+
delete: async ({ url }) => {
|
|
203
|
+
const assetUrl = new URL(url);
|
|
204
|
+
if (assetUrl.hostname !== "cdn.builder.io")
|
|
205
|
+
return false;
|
|
206
|
+
assetUrl.search = "";
|
|
207
|
+
assetUrl.hash = "";
|
|
208
|
+
const { resolveBuilderCredentials } = await import("../server/credential-provider.js");
|
|
209
|
+
const credentials = await resolveBuilderCredentials();
|
|
210
|
+
if (!credentials.privateKey || !credentials.publicKey)
|
|
211
|
+
return false;
|
|
212
|
+
const deleteUrl = new URL("/api/v1/assets/by-url", "https://cdn.builder.io");
|
|
213
|
+
deleteUrl.searchParams.set("url", assetUrl.toString());
|
|
214
|
+
deleteUrl.searchParams.set("apiKey", credentials.publicKey);
|
|
215
|
+
const response = await fetchWithTimeout(deleteUrl.toString(), {
|
|
216
|
+
method: "DELETE",
|
|
217
|
+
headers: { Authorization: `Bearer ${credentials.privateKey}` },
|
|
218
|
+
});
|
|
219
|
+
if (response.ok)
|
|
220
|
+
return true;
|
|
221
|
+
if (response.status === 404)
|
|
222
|
+
return false;
|
|
223
|
+
await assertOk(response, "Builder.io asset delete failed");
|
|
224
|
+
return false;
|
|
225
|
+
},
|
|
202
226
|
resumable: {
|
|
203
227
|
async startSession(filename, mimeType, maxBytes) {
|
|
204
228
|
const { resolveBuilderPrivateKey } = await import("../server/credential-provider.js");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
|
|
2
|
-
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
|
|
1
|
+
export type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
|
|
2
|
+
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
|
|
3
3
|
export { builderFileUploadProvider } from "./builder.js";
|
|
4
4
|
export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, type PreUploadAttachmentsResult, type PreUploadedImageAttachment, type PreUploadedFileAttachment, } from "./pre-upload-attachments.js";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
|
|
1
|
+
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
|
|
2
2
|
export { builderFileUploadProvider } from "./builder.js";
|
|
3
3
|
export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, } from "./pre-upload-attachments.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
|
|
1
|
+
import type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Register a file upload provider. Call from a server plugin or app
|
|
4
4
|
* bootstrap. Idempotent per id — later calls with the same id replace.
|
|
@@ -19,4 +19,5 @@ export declare function getActiveFileUploadProviderForRequest(): Promise<FileUpl
|
|
|
19
19
|
* configured. `null` is an explicit storage-setup state: callers must not
|
|
20
20
|
* turn the input into a base64 SQL fallback.
|
|
21
21
|
*/
|
|
22
|
+
export declare function deleteUploadedFile(providerId: string, input: FileUploadDeleteInput): Promise<boolean>;
|
|
22
23
|
export declare function uploadFile(input: FileUploadInput): Promise<FileUploadResult | null>;
|
|
@@ -59,6 +59,14 @@ export async function getActiveFileUploadProviderForRequest() {
|
|
|
59
59
|
* configured. `null` is an explicit storage-setup state: callers must not
|
|
60
60
|
* turn the input into a base64 SQL fallback.
|
|
61
61
|
*/
|
|
62
|
+
export async function deleteUploadedFile(providerId, input) {
|
|
63
|
+
const provider = providerId === builderFileUploadProvider.id
|
|
64
|
+
? builderFileUploadProvider
|
|
65
|
+
: providers.get(providerId);
|
|
66
|
+
if (!provider?.delete)
|
|
67
|
+
return false;
|
|
68
|
+
return provider.delete(input);
|
|
69
|
+
}
|
|
62
70
|
export async function uploadFile(input) {
|
|
63
71
|
const provider = await getActiveFileUploadProviderForRequest();
|
|
64
72
|
// User-registered providers (S3, etc.) may be configured by sync runtime
|
package/dist/file-upload/s3.js
CHANGED
|
@@ -146,6 +146,56 @@ async function putObject(config, key, data, contentType) {
|
|
|
146
146
|
}
|
|
147
147
|
return `${config.publicBaseUrl}/${key.split("/").map(encodePathSegment).join("/")}`;
|
|
148
148
|
}
|
|
149
|
+
async function deleteObject(config, key) {
|
|
150
|
+
const now = new Date();
|
|
151
|
+
const amzDate = now
|
|
152
|
+
.toISOString()
|
|
153
|
+
.replace(/[:-]|\.\d{3}/g, "")
|
|
154
|
+
.slice(0, 15) + "Z";
|
|
155
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
156
|
+
const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
|
|
157
|
+
const host = new URL(config.endpoint).host;
|
|
158
|
+
const canonicalUri = objectPath(config, key);
|
|
159
|
+
const payloadHash = await sha256(new Uint8Array(0));
|
|
160
|
+
const headers = {
|
|
161
|
+
host,
|
|
162
|
+
"x-amz-content-sha256": payloadHash,
|
|
163
|
+
"x-amz-date": amzDate,
|
|
164
|
+
};
|
|
165
|
+
const signedHeaderKeys = Object.keys(headers).sort();
|
|
166
|
+
const signedHeaders = signedHeaderKeys.join(";");
|
|
167
|
+
const canonicalHeaders = signedHeaderKeys
|
|
168
|
+
.map((header) => `${header}:${headers[header]}`)
|
|
169
|
+
.join("\n") + "\n";
|
|
170
|
+
const canonicalRequest = [
|
|
171
|
+
"DELETE",
|
|
172
|
+
canonicalUri,
|
|
173
|
+
"",
|
|
174
|
+
canonicalHeaders,
|
|
175
|
+
signedHeaders,
|
|
176
|
+
payloadHash,
|
|
177
|
+
].join("\n");
|
|
178
|
+
const requestHash = await sha256(new TextEncoder().encode(canonicalRequest));
|
|
179
|
+
const stringToSign = [
|
|
180
|
+
"AWS4-HMAC-SHA256",
|
|
181
|
+
amzDate,
|
|
182
|
+
credentialScope,
|
|
183
|
+
requestHash,
|
|
184
|
+
].join("\n");
|
|
185
|
+
const signature = toHex(await hmac(await signingKey(config.secretAccessKey, dateStamp, config.region), stringToSign));
|
|
186
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, ` +
|
|
187
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
188
|
+
const response = await fetch(`${config.endpoint}${canonicalUri}`, {
|
|
189
|
+
method: "DELETE",
|
|
190
|
+
headers: { ...headers, Authorization: authorization },
|
|
191
|
+
});
|
|
192
|
+
if (response.ok)
|
|
193
|
+
return true;
|
|
194
|
+
if (response.status === 404)
|
|
195
|
+
return false;
|
|
196
|
+
const detail = await response.text();
|
|
197
|
+
throw new Error(`S3 DeleteObject failed (${response.status}): ${detail || response.statusText}`);
|
|
198
|
+
}
|
|
149
199
|
function safeFilename(filename) {
|
|
150
200
|
const basename = filename?.split(/[\\/]/).pop()?.trim() || "attachment";
|
|
151
201
|
return (basename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "attachment");
|
|
@@ -163,6 +213,14 @@ export const s3FileUploadProvider = {
|
|
|
163
213
|
const key = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${safeFilename(filename)}`;
|
|
164
214
|
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
165
215
|
const url = await putObject(config, key, bytes, mimeType || "application/octet-stream");
|
|
166
|
-
return { url, provider: "s3" };
|
|
216
|
+
return { url, id: key, provider: "s3" };
|
|
217
|
+
},
|
|
218
|
+
delete: async ({ id }) => {
|
|
219
|
+
if (!id)
|
|
220
|
+
return false;
|
|
221
|
+
const config = await readRequestConfig();
|
|
222
|
+
if (!config)
|
|
223
|
+
return false;
|
|
224
|
+
return deleteObject(config, id);
|
|
167
225
|
},
|
|
168
226
|
};
|
|
@@ -28,6 +28,10 @@ export interface FileUploadResult {
|
|
|
28
28
|
/** The provider that handled the upload. */
|
|
29
29
|
provider: string;
|
|
30
30
|
}
|
|
31
|
+
export interface FileUploadDeleteInput {
|
|
32
|
+
url: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
}
|
|
31
35
|
/** Opaque session handle returned by {@link FileUploadProvider.resumable.startSession}.
|
|
32
36
|
* `sessionId` is provider-specific (GCS Location URI, S3 UploadId, etc.).
|
|
33
37
|
* `meta` holds any provider state needed for subsequent relay and complete calls. */
|
|
@@ -56,6 +60,8 @@ export interface FileUploadProvider {
|
|
|
56
60
|
isConfiguredForRequest?: () => Promise<boolean>;
|
|
57
61
|
/** Upload a file and return a URL. Throw on failure. */
|
|
58
62
|
upload: (input: FileUploadInput) => Promise<FileUploadResult>;
|
|
63
|
+
/** Delete a previously uploaded file when the provider supports it. */
|
|
64
|
+
delete?: (input: FileUploadDeleteInput) => Promise<boolean>;
|
|
59
65
|
/**
|
|
60
66
|
* Optional resumable/streaming upload capability.
|
|
61
67
|
* When present, create-recording will initialise a session and stream chunks
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
* DELETE /_agent-native/notifications/:id — delete
|
|
12
12
|
*/
|
|
13
13
|
export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
|
|
14
|
-
error?: undefined;
|
|
15
14
|
count: number;
|
|
16
15
|
updated?: undefined;
|
|
16
|
+
error?: undefined;
|
|
17
17
|
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
|
-
error?: undefined;
|
|
20
19
|
count?: undefined;
|
|
21
20
|
updated: number;
|
|
21
|
+
error?: undefined;
|
|
22
22
|
ok?: undefined;
|
|
23
23
|
} | {
|
|
24
24
|
count?: undefined;
|
|
@@ -26,8 +26,8 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
|
|
|
26
26
|
error: string;
|
|
27
27
|
ok?: undefined;
|
|
28
28
|
} | {
|
|
29
|
-
error?: undefined;
|
|
30
29
|
count?: undefined;
|
|
31
30
|
updated?: undefined;
|
|
31
|
+
error?: undefined;
|
|
32
32
|
ok: boolean;
|
|
33
33
|
}>>;
|
|
@@ -41,16 +41,16 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
41
41
|
thumbsUpRate: number;
|
|
42
42
|
avgEvalScore: number;
|
|
43
43
|
} | {
|
|
44
|
-
error?: undefined;
|
|
45
44
|
summary: import("./types.js").TraceSummary;
|
|
46
45
|
spans: import("./types.js").TraceSpan[];
|
|
47
46
|
id?: undefined;
|
|
47
|
+
error?: undefined;
|
|
48
48
|
ok?: undefined;
|
|
49
49
|
} | {
|
|
50
|
-
error?: undefined;
|
|
51
50
|
summary?: undefined;
|
|
52
51
|
spans?: undefined;
|
|
53
52
|
id: string;
|
|
53
|
+
error?: undefined;
|
|
54
54
|
ok?: undefined;
|
|
55
55
|
} | {
|
|
56
56
|
summary?: undefined;
|
|
@@ -59,9 +59,9 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
|
|
|
59
59
|
error: any;
|
|
60
60
|
ok?: undefined;
|
|
61
61
|
} | {
|
|
62
|
-
error?: undefined;
|
|
63
62
|
summary?: undefined;
|
|
64
63
|
spans?: undefined;
|
|
65
64
|
id?: undefined;
|
|
65
|
+
error?: undefined;
|
|
66
66
|
ok: boolean;
|
|
67
67
|
}>>;
|
|
@@ -3,11 +3,6 @@ export declare function registerPrivateBlobProvider(provider: PrivateBlobProvide
|
|
|
3
3
|
export declare function unregisterPrivateBlobProvider(id: string): void;
|
|
4
4
|
export declare function listPrivateBlobProviders(): PrivateBlobProvider[];
|
|
5
5
|
export declare function getActivePrivateBlobProvider(): PrivateBlobProvider | null;
|
|
6
|
-
/**
|
|
7
|
-
* @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
|
|
8
|
-
* instead. This writes the same value into the deprecated layer of the config
|
|
9
|
-
* ladder, so an explicit `defineAppConfig` call now wins over it.
|
|
10
|
-
*/
|
|
11
6
|
export declare function setPrivateBlobPublicUploadFallbackEnabled(enabled: boolean): void;
|
|
12
7
|
export declare function putPrivateBlob(input: PrivateBlobPutInput): Promise<PrivateBlobHandle | null>;
|
|
13
8
|
export declare function readPrivateBlob(handle: PrivateBlobHandle): Promise<PrivateBlobReadResult>;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
2
|
import { getAppConfig } from "../app-config/index.js";
|
|
3
|
-
import {
|
|
4
|
-
import { uploadFile } from "../file-upload/index.js";
|
|
3
|
+
import { deleteUploadedFile, uploadFile } from "../file-upload/index.js";
|
|
5
4
|
import { decryptSecretValue, encryptSecretValue, getSecretEncryptionKey, } from "../secrets/crypto.js";
|
|
6
5
|
const PUBLIC_UPLOAD_HANDLE_PREFIX = "public-upload:v1:";
|
|
7
6
|
const PUBLIC_UPLOAD_READ_RETRY_DELAYS_MS = [100, 250, 500];
|
|
8
7
|
const globals = globalThis;
|
|
9
8
|
const providers = (globals.__agentNativePrivateBlobProviders ??= new Map());
|
|
9
|
+
const publicUploadFallbackRef = (globals.__agentNativePrivateBlobPublicUploadFallback ??= {
|
|
10
|
+
enabled: true,
|
|
11
|
+
});
|
|
10
12
|
function toBytes(data) {
|
|
11
13
|
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
12
14
|
}
|
|
@@ -170,13 +172,16 @@ export function listPrivateBlobProviders() {
|
|
|
170
172
|
return [...providers.values()];
|
|
171
173
|
}
|
|
172
174
|
export function getActivePrivateBlobProvider() {
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
throw new Error(`Private blob
|
|
175
|
+
const selectedId = getAppConfig().privateBlob.provider;
|
|
176
|
+
if (selectedId) {
|
|
177
|
+
const selected = providers.get(selectedId);
|
|
178
|
+
if (!selected) {
|
|
179
|
+
throw new Error(`Private blob config selects '${selectedId}', but no provider with that id is registered`);
|
|
178
180
|
}
|
|
179
|
-
|
|
181
|
+
if (!selected.isConfigured()) {
|
|
182
|
+
throw new Error(`Private blob provider '${selectedId}' is selected but not configured`);
|
|
183
|
+
}
|
|
184
|
+
return selected;
|
|
180
185
|
}
|
|
181
186
|
for (const provider of providers.values()) {
|
|
182
187
|
if (provider.isConfigured())
|
|
@@ -184,20 +189,15 @@ export function getActivePrivateBlobProvider() {
|
|
|
184
189
|
}
|
|
185
190
|
return null;
|
|
186
191
|
}
|
|
187
|
-
/**
|
|
188
|
-
* @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
|
|
189
|
-
* instead. This writes the same value into the deprecated layer of the config
|
|
190
|
-
* ladder, so an explicit `defineAppConfig` call now wins over it.
|
|
191
|
-
*/
|
|
192
192
|
export function setPrivateBlobPublicUploadFallbackEnabled(enabled) {
|
|
193
|
-
|
|
194
|
-
privateBlob: { publicUploadFallback: enabled },
|
|
195
|
-
});
|
|
193
|
+
publicUploadFallbackRef.enabled = enabled;
|
|
196
194
|
}
|
|
197
195
|
export async function putPrivateBlob(input) {
|
|
198
196
|
const provider = getActivePrivateBlobProvider();
|
|
199
197
|
if (provider)
|
|
200
198
|
return provider.put(input);
|
|
199
|
+
if (!publicUploadFallbackRef.enabled)
|
|
200
|
+
return null;
|
|
201
201
|
if (!getAppConfig().privateBlob.publicUploadFallback)
|
|
202
202
|
return null;
|
|
203
203
|
return putViaEncryptedPublicUpload(input);
|
|
@@ -216,10 +216,17 @@ export async function deletePrivateBlob(handle) {
|
|
|
216
216
|
if (provider)
|
|
217
217
|
return provider.delete(handle);
|
|
218
218
|
if (isPublicUploadFallbackHandle(handle)) {
|
|
219
|
+
const descriptor = decodePublicUploadDescriptor(handle.id);
|
|
220
|
+
const deleted = await deleteUploadedFile(descriptor.uploadProvider, {
|
|
221
|
+
url: descriptor.url,
|
|
222
|
+
id: descriptor.uploadId,
|
|
223
|
+
});
|
|
219
224
|
return {
|
|
220
|
-
deleted
|
|
225
|
+
deleted,
|
|
221
226
|
provider: handle.provider,
|
|
222
|
-
|
|
227
|
+
...(deleted
|
|
228
|
+
? {}
|
|
229
|
+
: { reason: "backing upload provider could not delete the asset" }),
|
|
223
230
|
};
|
|
224
231
|
}
|
|
225
232
|
throw new Error(`No private blob provider registered for ${handle.provider}`);
|
|
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
|
|
|
48
48
|
}>;
|
|
49
49
|
/** DELETE /_agent-native/resources/:id — delete a resource */
|
|
50
50
|
export declare function handleDeleteResource(event: any): Promise<{
|
|
51
|
-
error: string;
|
|
52
51
|
ok?: undefined;
|
|
52
|
+
error: string;
|
|
53
53
|
} | {
|
|
54
54
|
error?: undefined;
|
|
55
55
|
ok: boolean;
|
|
@@ -26,8 +26,8 @@ export declare function createRealtimeTokenHandler(): import("h3").EventHandlerW
|
|
|
26
26
|
expiresAt?: undefined;
|
|
27
27
|
ttlSeconds?: undefined;
|
|
28
28
|
} | {
|
|
29
|
-
error?: undefined;
|
|
30
29
|
token: string;
|
|
31
30
|
expiresAt: string;
|
|
32
31
|
ttlSeconds: number;
|
|
32
|
+
error?: undefined;
|
|
33
33
|
}>>;
|
|
@@ -157,8 +157,26 @@ function injectDefaultSocialImageMeta(html, imageUrl) {
|
|
|
157
157
|
return html;
|
|
158
158
|
return html.slice(0, headCloseIdx) + tags.join("") + html.slice(headCloseIdx);
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* A "not found" shell is exactly as impersonal as a 200 shell, and leaving it
|
|
162
|
+
* uncached is expensive in a way that is invisible until it is not: every dead
|
|
163
|
+
* link, stale bookmark, renamed slug and crawler miss re-invoked the render
|
|
164
|
+
* function, and Netlify runs one request per container. Measured on
|
|
165
|
+
* www.agent-native.com, `/docs/<anything>` cost ~5s and cost the SAME ~5s on
|
|
166
|
+
* the very next identical request, because `no-cache` meant nothing was ever
|
|
167
|
+
* stored. Those invocations draw from the account-wide concurrency pool every
|
|
168
|
+
* other site shares, so one crawler walking dead links slowed unrelated apps.
|
|
169
|
+
*
|
|
170
|
+
* Only 404/410 join the shared policy. A 5xx is transient and must stay
|
|
171
|
+
* uncacheable — pinning one at the edge for the SWR window would turn a blip
|
|
172
|
+
* into an outage. 401/403 stay out because an auth-shaped response is the one
|
|
173
|
+
* error that could carry viewer-specific meaning.
|
|
174
|
+
*/
|
|
175
|
+
const CACHEABLE_ERROR_STATUSES = new Set([404, 410]);
|
|
160
176
|
function isSsrHtmlOrDataResponse(headers, status, pathname) {
|
|
161
|
-
if (status < 200
|
|
177
|
+
if (status < 200)
|
|
178
|
+
return false;
|
|
179
|
+
if (status >= 400 && !CACHEABLE_ERROR_STATUSES.has(status))
|
|
162
180
|
return false;
|
|
163
181
|
const contentType = headers.get("content-type")?.toLowerCase() ?? "";
|
|
164
182
|
if (contentType.includes("text/html"))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.168.
|
|
3
|
+
"version": "0.168.7",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|