@widgetic/creator 0.3.75 → 0.3.77
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/CreatorApp.svelte +150 -1
- package/dist/components/BrandKitPanel.svelte +18 -3
- package/dist/components/BrandKitPanel.svelte.d.ts +1 -0
- package/dist/utils/widgeticChatUpload.d.ts +10 -0
- package/dist/utils/widgeticChatUpload.js +57 -2
- package/dist/utils/widgeticChatUpload.test.d.ts +1 -0
- package/dist/utils/widgeticChatUpload.test.js +257 -0
- package/package.json +3 -3
package/dist/CreatorApp.svelte
CHANGED
|
@@ -1091,7 +1091,7 @@
|
|
|
1091
1091
|
// so the backend can load multi-turn LLM history (Aider-style incremental edits).
|
|
1092
1092
|
let currentChatConversationId: string | null = null;
|
|
1093
1093
|
let brandKitEnabled = false;
|
|
1094
|
-
let widgetLanguage = '
|
|
1094
|
+
let widgetLanguage: 'svelte' | 'react' = 'react';
|
|
1095
1095
|
let librariesEnabled = false;
|
|
1096
1096
|
let brandPrimary = '#314F2F';
|
|
1097
1097
|
let brandSecondary = '#7BA668';
|
|
@@ -7586,6 +7586,9 @@
|
|
|
7586
7586
|
...(lastCommitId ? { hasGeneratedCode: true } : {}),
|
|
7587
7587
|
});
|
|
7588
7588
|
if (lastCommitId) hasGeneratedCode = true;
|
|
7589
|
+
if (widget.id === focusedPanelWidgetId) {
|
|
7590
|
+
widgetLanguage = languageToShow(widget.id, widgetLanguage === 'svelte' ? 'svelte' : 'react');
|
|
7591
|
+
}
|
|
7589
7592
|
// Fetch history whenever the widget has code (generated or published).
|
|
7590
7593
|
// Gating on `lastPublishedVersion` alone meant a widget that had only
|
|
7591
7594
|
// ever been generated never loaded its history — so its version picker
|
|
@@ -8903,6 +8906,148 @@ Requirements:
|
|
|
8903
8906
|
return logs;
|
|
8904
8907
|
}
|
|
8905
8908
|
|
|
8909
|
+
function languageStorageKey(widgetId: string): string {
|
|
8910
|
+
return `widgetic-widget-language:${widgetId}`;
|
|
8911
|
+
}
|
|
8912
|
+
|
|
8913
|
+
function generationOptionsStorageKey(widgetId: string): string {
|
|
8914
|
+
return `widgetic-generation-options:${widgetId}`;
|
|
8915
|
+
}
|
|
8916
|
+
|
|
8917
|
+
type RememberedGenerationOptions = {
|
|
8918
|
+
language: 'svelte' | 'react';
|
|
8919
|
+
librariesEnabled: boolean;
|
|
8920
|
+
libraries: string[];
|
|
8921
|
+
brandKitEnabled: boolean;
|
|
8922
|
+
primary: string;
|
|
8923
|
+
secondary: string;
|
|
8924
|
+
title: string;
|
|
8925
|
+
sub: string;
|
|
8926
|
+
fonts: string[];
|
|
8927
|
+
libraryUploads: { name: string; content: string; encoding: string }[];
|
|
8928
|
+
fontUploads: { name: string; content: string; encoding: string }[];
|
|
8929
|
+
};
|
|
8930
|
+
|
|
8931
|
+
let generationOptionsMemory: Record<string, RememberedGenerationOptions> = {};
|
|
8932
|
+
|
|
8933
|
+
function currentGenerationOptions(): RememberedGenerationOptions {
|
|
8934
|
+
return {
|
|
8935
|
+
language: widgetLanguage === 'react' ? 'react' : 'svelte',
|
|
8936
|
+
librariesEnabled,
|
|
8937
|
+
libraries: [...brandKitLibraries],
|
|
8938
|
+
brandKitEnabled,
|
|
8939
|
+
primary: brandPrimary,
|
|
8940
|
+
secondary: brandSecondary,
|
|
8941
|
+
title: brandTitle,
|
|
8942
|
+
sub: brandSub,
|
|
8943
|
+
fonts: [...brandFonts],
|
|
8944
|
+
libraryUploads: libraryUploads.map((file) => ({ ...file })),
|
|
8945
|
+
fontUploads: fontUploads.map((file) => ({ ...file })),
|
|
8946
|
+
};
|
|
8947
|
+
}
|
|
8948
|
+
|
|
8949
|
+
function rememberGenerationOptions(widgetId: string): void {
|
|
8950
|
+
if (!widgetId) return;
|
|
8951
|
+
const options = currentGenerationOptions();
|
|
8952
|
+
generationOptionsMemory = { ...generationOptionsMemory, [widgetId]: options };
|
|
8953
|
+
try {
|
|
8954
|
+
localStorage.setItem(languageStorageKey(widgetId), options.language);
|
|
8955
|
+
localStorage.setItem(generationOptionsStorageKey(widgetId), JSON.stringify({
|
|
8956
|
+
language: options.language,
|
|
8957
|
+
librariesEnabled: options.librariesEnabled,
|
|
8958
|
+
libraries: options.libraries,
|
|
8959
|
+
brandKitEnabled: options.brandKitEnabled,
|
|
8960
|
+
primary: options.primary,
|
|
8961
|
+
secondary: options.secondary,
|
|
8962
|
+
title: options.title,
|
|
8963
|
+
sub: options.sub,
|
|
8964
|
+
fonts: options.fonts,
|
|
8965
|
+
}));
|
|
8966
|
+
} catch (error) {
|
|
8967
|
+
console.warn('[WidgetCreator] Could not store generation options', error);
|
|
8968
|
+
}
|
|
8969
|
+
}
|
|
8970
|
+
|
|
8971
|
+
function generatedLanguageStorageKey(widgetId: string): string {
|
|
8972
|
+
return `widgetic-generated-language:${widgetId}`;
|
|
8973
|
+
}
|
|
8974
|
+
|
|
8975
|
+
function readGeneratedLanguage(widgetId: string): 'svelte' | 'react' | null {
|
|
8976
|
+
try {
|
|
8977
|
+
const value = localStorage.getItem(generatedLanguageStorageKey(widgetId));
|
|
8978
|
+
return value === 'svelte' || value === 'react' ? value : null;
|
|
8979
|
+
} catch (error) {
|
|
8980
|
+
console.warn('[WidgetCreator] Could not read generated language', error);
|
|
8981
|
+
return null;
|
|
8982
|
+
}
|
|
8983
|
+
}
|
|
8984
|
+
|
|
8985
|
+
function rememberGeneratedLanguage(widgetId: string, language: 'svelte' | 'react'): void {
|
|
8986
|
+
try {
|
|
8987
|
+
localStorage.setItem(generatedLanguageStorageKey(widgetId), language);
|
|
8988
|
+
} catch (error) {
|
|
8989
|
+
console.warn('[WidgetCreator] Could not store generated language', error);
|
|
8990
|
+
}
|
|
8991
|
+
}
|
|
8992
|
+
|
|
8993
|
+
function widgetHasGeneratedCode(widgetId: string): boolean {
|
|
8994
|
+
const panel = getPanelState(widgetId);
|
|
8995
|
+
const widget = getWidgetById(widgetId);
|
|
8996
|
+
return !!(
|
|
8997
|
+
panel.lastCommitId
|
|
8998
|
+
|| loadCommitIdForWidget(widgetId)
|
|
8999
|
+
|| panel.lastPublishedVersion != null
|
|
9000
|
+
|| resolvePublishedVersionFromWidget(widget) !== null
|
|
9001
|
+
);
|
|
9002
|
+
}
|
|
9003
|
+
|
|
9004
|
+
/** A widget that already has code stays on the language it was generated with. Older widgets are Svelte. */
|
|
9005
|
+
function languageToShow(widgetId: string, storedLanguage: 'svelte' | 'react' | null): 'svelte' | 'react' {
|
|
9006
|
+
if (widgetHasGeneratedCode(widgetId)) {
|
|
9007
|
+
return readGeneratedLanguage(widgetId) ?? 'svelte';
|
|
9008
|
+
}
|
|
9009
|
+
return storedLanguage ?? 'react';
|
|
9010
|
+
}
|
|
9011
|
+
|
|
9012
|
+
function restoreGenerationOptions(widgetId: string): void {
|
|
9013
|
+
const inMemory = generationOptionsMemory[widgetId];
|
|
9014
|
+
let stored: Partial<RememberedGenerationOptions> | null = null;
|
|
9015
|
+
if (!inMemory) {
|
|
9016
|
+
try {
|
|
9017
|
+
const raw = localStorage.getItem(generationOptionsStorageKey(widgetId));
|
|
9018
|
+
stored = raw ? JSON.parse(raw) : null;
|
|
9019
|
+
} catch (error) {
|
|
9020
|
+
console.warn('[WidgetCreator] Could not read generation options', error);
|
|
9021
|
+
}
|
|
9022
|
+
}
|
|
9023
|
+
const options = inMemory || stored;
|
|
9024
|
+
const storedLanguage = options?.language === 'svelte' || options?.language === 'react' ? options.language : null;
|
|
9025
|
+
widgetLanguage = languageToShow(widgetId, storedLanguage);
|
|
9026
|
+
librariesEnabled = !!options?.librariesEnabled;
|
|
9027
|
+
brandKitLibraries = Array.isArray(options?.libraries) ? [...options.libraries] : [];
|
|
9028
|
+
brandKitEnabled = !!options?.brandKitEnabled;
|
|
9029
|
+
brandPrimary = options?.primary || '#314F2F';
|
|
9030
|
+
brandSecondary = options?.secondary || '#7BA668';
|
|
9031
|
+
brandTitle = options?.title || '#0F2010';
|
|
9032
|
+
brandSub = options?.sub || '#5C6B58';
|
|
9033
|
+
brandFonts = Array.isArray(options?.fonts) && options.fonts.length > 0 ? [...options.fonts] : ['Inter'];
|
|
9034
|
+
libraryUploads = inMemory?.libraryUploads ? inMemory.libraryUploads.map((file) => ({ ...file })) : [];
|
|
9035
|
+
fontUploads = inMemory?.fontUploads ? inMemory.fontUploads.map((file) => ({ ...file })) : [];
|
|
9036
|
+
}
|
|
9037
|
+
|
|
9038
|
+
function languageForGeneration(widgetId: string): 'svelte' | 'react' {
|
|
9039
|
+
const hasCode = widgetHasGeneratedCode(widgetId);
|
|
9040
|
+
const language: 'svelte' | 'react' = hasCode
|
|
9041
|
+
? (readGeneratedLanguage(widgetId) ?? 'svelte')
|
|
9042
|
+
: (widgetLanguage === 'svelte' ? 'svelte' : 'react');
|
|
9043
|
+
widgetLanguage = language;
|
|
9044
|
+
rememberGeneratedLanguage(widgetId, language);
|
|
9045
|
+
rememberGenerationOptions(widgetId);
|
|
9046
|
+
return language;
|
|
9047
|
+
}
|
|
9048
|
+
|
|
9049
|
+
$: if (focusedPanelWidgetId) rememberGenerationOptions(focusedPanelWidgetId);
|
|
9050
|
+
|
|
8906
9051
|
async function performCodeGeneration(
|
|
8907
9052
|
prompt: string,
|
|
8908
9053
|
options: {
|
|
@@ -9191,6 +9336,7 @@ Requirements:
|
|
|
9191
9336
|
compileFixOnly: options.compileFixOnly === true,
|
|
9192
9337
|
skipIntentGate: options.skipIntentGate === true,
|
|
9193
9338
|
chipIntent: options.chipIntent,
|
|
9339
|
+
language: languageForGeneration(generationWidgetId),
|
|
9194
9340
|
brandKit: brandKitForRequest(),
|
|
9195
9341
|
} as GenerateCodeRequest
|
|
9196
9342
|
});
|
|
@@ -12042,9 +12188,11 @@ Requirements:
|
|
|
12042
12188
|
notifyHostWidgetName(getWidgetById(widgetId) ?? selectedWidget);
|
|
12043
12189
|
return;
|
|
12044
12190
|
}
|
|
12191
|
+
if (focusedPanelWidgetId) rememberGenerationOptions(focusedPanelWidgetId);
|
|
12045
12192
|
captureFocusedPanelSnapshot();
|
|
12046
12193
|
ensurePanelState(widgetId);
|
|
12047
12194
|
focusedPanelWidgetId = widgetId;
|
|
12195
|
+
restoreGenerationOptions(widgetId);
|
|
12048
12196
|
raiseWidgetDetailsPanelToFront(widgetId);
|
|
12049
12197
|
isHistoryOpen = false;
|
|
12050
12198
|
const widget = getWidgetById(widgetId);
|
|
@@ -16869,6 +17017,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
|
|
|
16869
17017
|
bind:sub={brandSub}
|
|
16870
17018
|
bind:fonts={brandFonts}
|
|
16871
17019
|
showLanguage={debugMode}
|
|
17020
|
+
languageLocked={!!(generatingWidgetIds[panelWidgetId] || ps.lastCommitId || ps.lastPublishedVersion != null)}
|
|
16872
17021
|
bind:language={widgetLanguage}
|
|
16873
17022
|
/>
|
|
16874
17023
|
{/snippet}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
fontUploads?: { name: string; content: string; encoding: string }[];
|
|
32
32
|
showLanguage?: boolean;
|
|
33
33
|
language?: string;
|
|
34
|
+
languageLocked?: boolean;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
const maxFonts = 3;
|
|
@@ -54,7 +55,8 @@
|
|
|
54
55
|
libraryUploads = $bindable([] as { name: string; content: string; encoding: string }[]),
|
|
55
56
|
fontUploads = $bindable([] as { name: string; content: string; encoding: string }[]),
|
|
56
57
|
showLanguage = false,
|
|
57
|
-
language = $bindable('
|
|
58
|
+
language = $bindable('react'),
|
|
59
|
+
languageLocked = false,
|
|
58
60
|
}: Props = $props();
|
|
59
61
|
|
|
60
62
|
const libraryChoices = [
|
|
@@ -281,6 +283,16 @@
|
|
|
281
283
|
<div class="generation-libraries-row flex w-full flex-col gap-2">
|
|
282
284
|
<div class="generation-libraries-checks flex flex-nowrap items-center gap-1">
|
|
283
285
|
{#if showLanguage}
|
|
286
|
+
{#if languageLocked}
|
|
287
|
+
<HoverTooltip class="inline-flex shrink-0" text="Language is fixed after the first generation">
|
|
288
|
+
<span
|
|
289
|
+
class="generation-language-select flex h-10 w-[4.75rem] shrink-0 cursor-default items-center justify-between gap-0.5 rounded-lg border border-gray-300 bg-white px-1.5 text-sm text-gray-900 opacity-60"
|
|
290
|
+
aria-disabled="true"
|
|
291
|
+
>
|
|
292
|
+
<span class="truncate">{language === 'react' ? 'React' : 'Svelte'}</span>
|
|
293
|
+
</span>
|
|
294
|
+
</HoverTooltip>
|
|
295
|
+
{:else}
|
|
284
296
|
<HoverTooltip class="inline-flex shrink-0" text="Select language">
|
|
285
297
|
<DropdownMenu.Root>
|
|
286
298
|
<DropdownMenu.Trigger
|
|
@@ -303,11 +315,14 @@
|
|
|
303
315
|
</DropdownMenu.Content>
|
|
304
316
|
</DropdownMenu.Root>
|
|
305
317
|
</HoverTooltip>
|
|
318
|
+
{/if}
|
|
306
319
|
{/if}
|
|
320
|
+
{#if showPresetLibraries}
|
|
307
321
|
<label class="generation-option-toggle flex h-10 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-2 text-sm {librariesEnabled ? 'border-[#314F2F] bg-[#314F2F] text-white' : 'border-gray-300 bg-white text-gray-800'}">
|
|
308
322
|
<input class="generation-option-check cursor-pointer" type="checkbox" checked={librariesEnabled} onchange={onLibrariesChange} />
|
|
309
323
|
Libraries
|
|
310
324
|
</label>
|
|
325
|
+
{/if}
|
|
311
326
|
{#if librariesEnabled && showPresetLibraries}
|
|
312
327
|
{#each libraryChoices as choice (choice.id)}
|
|
313
328
|
<label class="generation-library flex items-center gap-1 text-xs text-gray-700">
|
|
@@ -321,7 +336,7 @@
|
|
|
321
336
|
</label>
|
|
322
337
|
{/each}
|
|
323
338
|
{/if}
|
|
324
|
-
{#if librariesEnabled}
|
|
339
|
+
{#if librariesEnabled && showPresetLibraries}
|
|
325
340
|
<HoverTooltip class="inline-flex shrink-0" text="Add Library">
|
|
326
341
|
<button
|
|
327
342
|
type="button"
|
|
@@ -332,7 +347,7 @@
|
|
|
332
347
|
</HoverTooltip>
|
|
333
348
|
{/if}
|
|
334
349
|
</div>
|
|
335
|
-
{#if librariesEnabled}
|
|
350
|
+
{#if librariesEnabled && showPresetLibraries}
|
|
336
351
|
{#each libraryRows as row (row.id)}
|
|
337
352
|
<div class="generation-source-row flex items-center gap-1">
|
|
338
353
|
<FileUrlSourceControl
|
|
@@ -26,6 +26,7 @@ interface Props {
|
|
|
26
26
|
}[];
|
|
27
27
|
showLanguage?: boolean;
|
|
28
28
|
language?: string;
|
|
29
|
+
languageLocked?: boolean;
|
|
29
30
|
}
|
|
30
31
|
declare const BrandKitPanel: import("svelte").Component<Props, {}, "brandKitEnabled" | "librariesEnabled" | "primary" | "secondary" | "title" | "sub" | "fonts" | "libraries" | "libraryGitUrl" | "libraryFileName" | "libraryUploads" | "fontUploads" | "language">;
|
|
31
32
|
type BrandKitPanel = ReturnType<typeof BrandKitPanel>;
|
|
@@ -19,6 +19,16 @@ export type ChatUploadOptions = {
|
|
|
19
19
|
onProgress?: (percentage: number) => void;
|
|
20
20
|
abortSignal?: AbortSignal;
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* POST …/complete. A 409 `TUS_UPLOAD_BUSY` means another request still has the upload (the chunk that
|
|
24
|
+
* just finished, or a retried one), so ask again after a short pause. Every other answer is final: a
|
|
25
|
+
* 409 `TUS_UPLOAD_INCOMPLETE` means chunks are missing, and asking again cannot bring them.
|
|
26
|
+
* Returns the last answer; the caller tells success from failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function requestTusComplete(url: string, init: RequestInit, options?: {
|
|
29
|
+
retryDelaysMs?: readonly number[];
|
|
30
|
+
abortSignal?: AbortSignal;
|
|
31
|
+
}): Promise<Response>;
|
|
22
32
|
export declare function shouldUseTusForChatUpload(file: File): boolean;
|
|
23
33
|
/**
|
|
24
34
|
* Upload a chat attachment via multipart (≤10MB) or TUS (>10MB).
|
|
@@ -2,12 +2,62 @@ import * as tus from 'tus-js-client';
|
|
|
2
2
|
/** Matches api-gateway MULTIPART_MAX_UPLOAD_BYTES. */
|
|
3
3
|
export const CHAT_MULTIPART_MAX_BYTES = 10 * 1024 * 1024;
|
|
4
4
|
const DEFAULT_TUS_CHUNK_SIZE = 5 * 1024 * 1024;
|
|
5
|
+
/** The `error.code` api-gateway answers `POST …/complete` with (HTTP 409) while another request still has the upload. */
|
|
6
|
+
const TUS_UPLOAD_BUSY_CODE = 'TUS_UPLOAD_BUSY';
|
|
7
|
+
/** Pauses before asking again after a "busy" answer: 0.5, 1, 2, 4 and 8 seconds, about 15 seconds in all. */
|
|
8
|
+
const COMPLETE_BUSY_RETRY_DELAYS_MS = [500, 1000, 2000, 4000, 8000];
|
|
5
9
|
function parseErrorMessage(response, fallback) {
|
|
6
10
|
return response
|
|
7
11
|
.json()
|
|
8
12
|
.then((json) => json?.message || json?.error?.message || fallback)
|
|
9
13
|
.catch(() => fallback);
|
|
10
14
|
}
|
|
15
|
+
/** The `error.code` of a failed answer, or undefined when the body is not the API's error envelope. */
|
|
16
|
+
function readErrorCode(response) {
|
|
17
|
+
return response
|
|
18
|
+
.json()
|
|
19
|
+
.then((json) => (typeof json?.error?.code === 'string' ? json.error.code : undefined))
|
|
20
|
+
.catch(() => undefined);
|
|
21
|
+
}
|
|
22
|
+
/** Resolves after `delayMs`, or at once when the upload is cancelled meanwhile. */
|
|
23
|
+
function waitBeforeRetry(delayMs, abortSignal) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
if (abortSignal?.aborted) {
|
|
26
|
+
resolve();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const finish = () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
abortSignal?.removeEventListener('abort', finish);
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(finish, delayMs);
|
|
35
|
+
abortSignal?.addEventListener('abort', finish, { once: true });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* POST …/complete. A 409 `TUS_UPLOAD_BUSY` means another request still has the upload (the chunk that
|
|
40
|
+
* just finished, or a retried one), so ask again after a short pause. Every other answer is final: a
|
|
41
|
+
* 409 `TUS_UPLOAD_INCOMPLETE` means chunks are missing, and asking again cannot bring them.
|
|
42
|
+
* Returns the last answer; the caller tells success from failure.
|
|
43
|
+
*/
|
|
44
|
+
export async function requestTusComplete(url, init, options = {}) {
|
|
45
|
+
const retryDelaysMs = options.retryDelaysMs ?? COMPLETE_BUSY_RETRY_DELAYS_MS;
|
|
46
|
+
let retriesDone = 0;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const response = await fetch(url, init);
|
|
49
|
+
if (response.status !== 409 || retriesDone >= retryDelaysMs.length)
|
|
50
|
+
return response;
|
|
51
|
+
// Read a copy, so the caller can still read the message of an answer that turns out to be final.
|
|
52
|
+
if ((await readErrorCode(response.clone())) !== TUS_UPLOAD_BUSY_CODE)
|
|
53
|
+
return response;
|
|
54
|
+
console.warn(`[ChatUpload] Upload is busy; asking again in ${retryDelaysMs[retriesDone]} ms (retry ${retriesDone + 1} of ${retryDelaysMs.length})`);
|
|
55
|
+
await waitBeforeRetry(retryDelaysMs[retriesDone], options.abortSignal);
|
|
56
|
+
if (options.abortSignal?.aborted)
|
|
57
|
+
return response;
|
|
58
|
+
retriesDone += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
11
61
|
function mapUploadResponse(data, file) {
|
|
12
62
|
const url = data.url || '';
|
|
13
63
|
if (!url) {
|
|
@@ -101,14 +151,19 @@ function uploadTus(file, config, options = {}) {
|
|
|
101
151
|
return;
|
|
102
152
|
}
|
|
103
153
|
try {
|
|
104
|
-
const completeResponse = await
|
|
154
|
+
const completeResponse = await requestTusComplete(`${endpoint}/${tusId}/complete`, {
|
|
105
155
|
method: 'POST',
|
|
106
156
|
headers: {
|
|
107
157
|
Authorization: `Bearer ${token}`,
|
|
108
158
|
'Content-Type': 'application/json',
|
|
109
159
|
},
|
|
110
|
-
});
|
|
160
|
+
}, { abortSignal: options.abortSignal });
|
|
111
161
|
if (!completeResponse.ok) {
|
|
162
|
+
// Cancelled while waiting to ask again: say so, instead of repeating the server's "busy" answer.
|
|
163
|
+
if (options.abortSignal?.aborted) {
|
|
164
|
+
reject(new Error('Upload cancelled.'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
112
167
|
reject(new Error(await parseErrorMessage(completeResponse, `Complete failed (${completeResponse.status})`)));
|
|
113
168
|
return;
|
|
114
169
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CHAT_MULTIPART_MAX_BYTES, requestTusComplete, uploadChatFileToWidgeticApi } from './widgeticChatUpload';
|
|
3
|
+
// The real client opens connections and sends the file in chunks. This stand-in "finishes" the moment it is
|
|
4
|
+
// started, so each test only decides how the gateway answers `POST …/complete`.
|
|
5
|
+
vi.mock('tus-js-client', () => {
|
|
6
|
+
class Upload {
|
|
7
|
+
file;
|
|
8
|
+
options;
|
|
9
|
+
url = 'https://api.test/v1/uploads/tus/0b9e0f7e-6d2c-4a39-8d36-2f6a5f0c7a11~0123456789abcdef0123456789abcdef';
|
|
10
|
+
constructor(file, options) {
|
|
11
|
+
this.file = file;
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
findPreviousUploads() {
|
|
15
|
+
return Promise.resolve([]);
|
|
16
|
+
}
|
|
17
|
+
resumeFromPreviousUpload() { }
|
|
18
|
+
start() {
|
|
19
|
+
this.options.onSuccess();
|
|
20
|
+
}
|
|
21
|
+
abort() {
|
|
22
|
+
return Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { Upload };
|
|
26
|
+
});
|
|
27
|
+
const COMPLETE_URL = 'https://api.test/v1/uploads/tus/0b9e0f7e-6d2c-4a39-8d36-2f6a5f0c7a11~0123456789abcdef0123456789abcdef/complete';
|
|
28
|
+
const BUSY_MESSAGE = 'The upload is in use by another request. Try again in a moment.';
|
|
29
|
+
const INCOMPLETE_MESSAGE = 'Upload is not complete: 4 of 10 bytes received. Send the remaining chunks, then complete the upload.';
|
|
30
|
+
const config = { apiBasePath: 'https://api.test/v1/', accessToken: 'Bearer test-token' };
|
|
31
|
+
/** A file big enough to be sent with TUS, without allocating its bytes (the stand-in never reads them). */
|
|
32
|
+
function largeFile() {
|
|
33
|
+
const file = new File(['0123456789'], 'clip.mp4', { type: 'video/mp4' });
|
|
34
|
+
Object.defineProperty(file, 'size', { value: CHAT_MULTIPART_MAX_BYTES + 1 });
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
function jsonResponse(status, body) {
|
|
38
|
+
return new Response(JSON.stringify(body), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { 'Content-Type': 'application/json' }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** What api-gateway answers while another request still has the upload. */
|
|
44
|
+
const busyAnswer = () => jsonResponse(409, {
|
|
45
|
+
success: false,
|
|
46
|
+
message: BUSY_MESSAGE,
|
|
47
|
+
error: { code: 'TUS_UPLOAD_BUSY', message: BUSY_MESSAGE }
|
|
48
|
+
});
|
|
49
|
+
/** What api-gateway answers while chunks are missing: asking again cannot help. */
|
|
50
|
+
const incompleteAnswer = () => jsonResponse(409, {
|
|
51
|
+
success: false,
|
|
52
|
+
message: INCOMPLETE_MESSAGE,
|
|
53
|
+
error: { code: 'TUS_UPLOAD_INCOMPLETE', message: INCOMPLETE_MESSAGE }
|
|
54
|
+
});
|
|
55
|
+
const completedAnswer = () => jsonResponse(200, {
|
|
56
|
+
success: true,
|
|
57
|
+
data: {
|
|
58
|
+
id: 'file_1',
|
|
59
|
+
url: 'https://cdn.test/clip.mp4',
|
|
60
|
+
file_name: 'clip.mp4',
|
|
61
|
+
file_size: 10,
|
|
62
|
+
mime_type: 'video/mp4'
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const fetchMock = vi.fn();
|
|
66
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
67
|
+
/** Lets every pause end at once and records how long each one was asked to be. */
|
|
68
|
+
function skipPauses() {
|
|
69
|
+
const requestedPauses = [];
|
|
70
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler, delayMs) => {
|
|
71
|
+
requestedPauses.push(delayMs ?? 0);
|
|
72
|
+
return realSetTimeout(handler, 0);
|
|
73
|
+
}));
|
|
74
|
+
return requestedPauses;
|
|
75
|
+
}
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
fetchMock.mockReset();
|
|
78
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
79
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
80
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
81
|
+
});
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.restoreAllMocks();
|
|
84
|
+
vi.unstubAllGlobals();
|
|
85
|
+
});
|
|
86
|
+
describe('requestTusComplete', () => {
|
|
87
|
+
const init = { method: 'POST', headers: { Authorization: 'Bearer test-token' } };
|
|
88
|
+
it('asks once when the first answer is a success', async () => {
|
|
89
|
+
fetchMock.mockResolvedValueOnce(completedAnswer());
|
|
90
|
+
const response = await requestTusComplete(COMPLETE_URL, init);
|
|
91
|
+
expect(response.ok).toBe(true);
|
|
92
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledWith(COMPLETE_URL, init);
|
|
94
|
+
});
|
|
95
|
+
it('asks again after a "busy" answer and returns the answer that follows', async () => {
|
|
96
|
+
fetchMock
|
|
97
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
98
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
99
|
+
.mockResolvedValueOnce(completedAnswer());
|
|
100
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
101
|
+
expect(response.ok).toBe(true);
|
|
102
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
103
|
+
});
|
|
104
|
+
it('pauses for each given delay, in order, before it asks again', async () => {
|
|
105
|
+
fetchMock
|
|
106
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
107
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
108
|
+
.mockResolvedValueOnce(completedAnswer());
|
|
109
|
+
const requestedPauses = skipPauses();
|
|
110
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [1111, 2222, 3333] });
|
|
111
|
+
expect(requestedPauses.filter((delayMs) => delayMs > 1000)).toEqual([1111, 2222]);
|
|
112
|
+
});
|
|
113
|
+
it('gives up after the last delay and returns the "busy" answer, message still readable', async () => {
|
|
114
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
115
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0] });
|
|
116
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
117
|
+
expect(response.status).toBe(409);
|
|
118
|
+
expect(await response.json()).toMatchObject({
|
|
119
|
+
error: { code: 'TUS_UPLOAD_BUSY', message: BUSY_MESSAGE }
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
it('does not ask again when chunks are missing (409 TUS_UPLOAD_INCOMPLETE)', async () => {
|
|
123
|
+
fetchMock.mockResolvedValueOnce(incompleteAnswer());
|
|
124
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
125
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(response.status).toBe(409);
|
|
127
|
+
expect(await response.json()).toMatchObject({ error: { code: 'TUS_UPLOAD_INCOMPLETE' } });
|
|
128
|
+
});
|
|
129
|
+
it('does not ask again when a 409 carries another code', async () => {
|
|
130
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(409, { success: false, error: { code: 'SOMETHING_ELSE' } }));
|
|
131
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
132
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
it('does not ask again when the "busy" code comes with another status', async () => {
|
|
135
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(503, { success: false, error: { code: 'TUS_UPLOAD_BUSY' } }));
|
|
136
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
137
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
it('does not ask again when a 409 has no JSON body', async () => {
|
|
140
|
+
fetchMock.mockResolvedValueOnce(new Response('<html>Conflict</html>', { status: 409 }));
|
|
141
|
+
await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
142
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
143
|
+
});
|
|
144
|
+
it.each([
|
|
145
|
+
[404, 'TUS_UPLOAD_NOT_FOUND'],
|
|
146
|
+
[413, 'QUOTA_STORAGE_EXCEEDED'],
|
|
147
|
+
[415, 'UPLOAD_UNSUPPORTED_TYPE'],
|
|
148
|
+
[503, 'TUS_DISABLED']
|
|
149
|
+
])('does not ask again after a %i %s', async (status, code) => {
|
|
150
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(status, { success: false, error: { code } }));
|
|
151
|
+
const response = await requestTusComplete(COMPLETE_URL, init, { retryDelaysMs: [0, 0, 0] });
|
|
152
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
153
|
+
expect(response.status).toBe(status);
|
|
154
|
+
});
|
|
155
|
+
it('stops waiting when the upload is cancelled during a pause, and does not ask again', async () => {
|
|
156
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
// A pause long enough that only the cancel can end it.
|
|
159
|
+
const pauseStarted = new Promise((resolve) => {
|
|
160
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler, delayMs) => {
|
|
161
|
+
if (delayMs === 600_000)
|
|
162
|
+
resolve();
|
|
163
|
+
return realSetTimeout(handler, delayMs);
|
|
164
|
+
}));
|
|
165
|
+
});
|
|
166
|
+
const answer = requestTusComplete(COMPLETE_URL, init, {
|
|
167
|
+
retryDelaysMs: [600_000],
|
|
168
|
+
abortSignal: controller.signal
|
|
169
|
+
});
|
|
170
|
+
await pauseStarted;
|
|
171
|
+
controller.abort();
|
|
172
|
+
const response = await answer;
|
|
173
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(response.status).toBe(409);
|
|
175
|
+
});
|
|
176
|
+
it('does not wait or ask again when the upload was already cancelled', async () => {
|
|
177
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
controller.abort();
|
|
180
|
+
const response = await requestTusComplete(COMPLETE_URL, init, {
|
|
181
|
+
retryDelaysMs: [600_000],
|
|
182
|
+
abortSignal: controller.signal
|
|
183
|
+
});
|
|
184
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(response.status).toBe(409);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe('uploadChatFileToWidgeticApi (file above the multipart limit)', () => {
|
|
189
|
+
it('finishes the upload when /complete answers at once', async () => {
|
|
190
|
+
fetchMock.mockResolvedValueOnce(completedAnswer());
|
|
191
|
+
const result = await uploadChatFileToWidgeticApi(largeFile(), config);
|
|
192
|
+
expect(result).toEqual({
|
|
193
|
+
id: 'file_1',
|
|
194
|
+
url: 'https://cdn.test/clip.mp4',
|
|
195
|
+
fileType: 'video/mp4',
|
|
196
|
+
fileName: 'clip.mp4',
|
|
197
|
+
fileSize: 10
|
|
198
|
+
});
|
|
199
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
200
|
+
const [calledUrl, calledInit] = fetchMock.mock.calls[0];
|
|
201
|
+
expect(calledUrl).toBe(COMPLETE_URL);
|
|
202
|
+
expect(calledInit).toMatchObject({
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { Authorization: 'Bearer test-token' }
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
it('asks again after 0.5 s and then 1 s when /complete says the upload is busy, then finishes', async () => {
|
|
208
|
+
fetchMock
|
|
209
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
210
|
+
.mockResolvedValueOnce(busyAnswer())
|
|
211
|
+
.mockResolvedValueOnce(completedAnswer());
|
|
212
|
+
const requestedPauses = skipPauses();
|
|
213
|
+
const result = await uploadChatFileToWidgeticApi(largeFile(), config);
|
|
214
|
+
expect(result.id).toBe('file_1');
|
|
215
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
216
|
+
expect(requestedPauses.filter((delayMs) => delayMs >= 500)).toEqual([500, 1000]);
|
|
217
|
+
});
|
|
218
|
+
it('gives up after five more tries and fails with the message of the last answer', async () => {
|
|
219
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
220
|
+
const requestedPauses = skipPauses();
|
|
221
|
+
await expect(uploadChatFileToWidgeticApi(largeFile(), config)).rejects.toThrow(BUSY_MESSAGE);
|
|
222
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
223
|
+
expect(requestedPauses.filter((delayMs) => delayMs >= 500)).toEqual([
|
|
224
|
+
500, 1000, 2000, 4000, 8000
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
it('does not ask again when chunks are missing, and says so', async () => {
|
|
228
|
+
fetchMock.mockResolvedValueOnce(incompleteAnswer());
|
|
229
|
+
const requestedPauses = skipPauses();
|
|
230
|
+
await expect(uploadChatFileToWidgeticApi(largeFile(), config)).rejects.toThrow(INCOMPLETE_MESSAGE);
|
|
231
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
232
|
+
expect(requestedPauses.filter((delayMs) => delayMs >= 500)).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
it('says the upload was cancelled, not that it was busy, when it is cancelled while waiting to ask again', async () => {
|
|
235
|
+
fetchMock.mockImplementation(async () => busyAnswer());
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const pauseStarted = new Promise((resolve) => {
|
|
238
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler, delayMs) => {
|
|
239
|
+
// Hold the first pause (0.5 s) open until the cancel arrives.
|
|
240
|
+
if (delayMs === 500) {
|
|
241
|
+
resolve();
|
|
242
|
+
return realSetTimeout(handler, 600_000);
|
|
243
|
+
}
|
|
244
|
+
return realSetTimeout(handler, 0);
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
247
|
+
const pending = uploadChatFileToWidgeticApi(largeFile(), config, {
|
|
248
|
+
abortSignal: controller.signal
|
|
249
|
+
});
|
|
250
|
+
// Keep the rejection from being reported as unhandled before the test reads it.
|
|
251
|
+
const outcome = pending.then(() => 'resolved', (error) => error.message);
|
|
252
|
+
await pauseStarted;
|
|
253
|
+
controller.abort();
|
|
254
|
+
expect(await outcome).toBe('Upload cancelled.');
|
|
255
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
256
|
+
});
|
|
257
|
+
});
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@widgetic/creator",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.77",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"peerDependencies": {
|
|
6
6
|
"@sveltejs/kit": "^2.20.0",
|
|
7
|
-
"@widgetic/api-sdk": "^1.0.
|
|
7
|
+
"@widgetic/api-sdk": "^1.0.23",
|
|
8
8
|
"@widgetic/cache-layer": "^0.1.2",
|
|
9
9
|
"@widgetic/canvas": "^0.5.15",
|
|
10
10
|
"@widgetic/chat": "^0.1.17",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@tailwindcss/postcss": "^4.1.4",
|
|
69
69
|
"@tailwindcss/typography": "^0.5.15",
|
|
70
70
|
"@types/node": "26.4.0",
|
|
71
|
-
"@widgetic/api-sdk": "^1.0.
|
|
71
|
+
"@widgetic/api-sdk": "^1.0.23",
|
|
72
72
|
"@widgetic/cache-layer": "^0.1.2",
|
|
73
73
|
"@widgetic/canvas": "^0.5.15",
|
|
74
74
|
"@widgetic/chat": "^0.1.17",
|