@podosoft/podokit 1.3.0 → 1.4.1
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/README.md +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { createApiClient } from "@podosoft/podokit-api-client";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { resumeUpload, uploadFile, type UploadSession } from "./uploads";
|
|
4
|
+
|
|
5
|
+
const PART = 10;
|
|
6
|
+
|
|
7
|
+
// The default client in ./api, pointed at whichever fake server a test installs.
|
|
8
|
+
const defaultClient = vi.hoisted(() => ({
|
|
9
|
+
fetch: (async () => {
|
|
10
|
+
throw new Error("No fake server installed");
|
|
11
|
+
}) as (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock("./api", async () => {
|
|
14
|
+
const { createApiClient } = await import("@podosoft/podokit-api-client");
|
|
15
|
+
const fetch: typeof globalThis.fetch = Object.assign(
|
|
16
|
+
(input: RequestInfo | URL, init?: RequestInit) => defaultClient.fetch(input, init),
|
|
17
|
+
{ preconnect: globalThis.fetch.preconnect },
|
|
18
|
+
);
|
|
19
|
+
return { api: createApiClient({ fetch }) };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
interface Session {
|
|
23
|
+
id: string;
|
|
24
|
+
size: number;
|
|
25
|
+
partCount: number;
|
|
26
|
+
parts: Map<number, number>;
|
|
27
|
+
status: "open" | "completed" | "aborted";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Reply = Response | undefined;
|
|
31
|
+
|
|
32
|
+
/** An in-memory stand-in for the API's /files/uploads routes. */
|
|
33
|
+
class FakeUploads {
|
|
34
|
+
readonly sessions = new Map<string, Session>();
|
|
35
|
+
readonly log: string[] = [];
|
|
36
|
+
/** Answers the next matching request instead of the fake, once per entry. */
|
|
37
|
+
readonly overrides: Array<{ match: string; reply: () => Response }> = [];
|
|
38
|
+
private nextId = 1;
|
|
39
|
+
inFlight = 0;
|
|
40
|
+
maxInFlight = 0;
|
|
41
|
+
/** Resolves part uploads only when released, to observe concurrency. */
|
|
42
|
+
gate?: Promise<void>;
|
|
43
|
+
|
|
44
|
+
readonly fetch = async (input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> => {
|
|
45
|
+
const url = new URL(String(input), "http://localhost");
|
|
46
|
+
const method = init.method ?? "GET";
|
|
47
|
+
const route = `${method} ${url.pathname}`;
|
|
48
|
+
this.log.push(route);
|
|
49
|
+
if (init.signal?.aborted) throw init.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
50
|
+
const override = this.overrides.findIndex((entry) => route.endsWith(entry.match));
|
|
51
|
+
if (override >= 0) {
|
|
52
|
+
const [entry] = this.overrides.splice(override, 1);
|
|
53
|
+
return entry?.reply() ?? json(500, {});
|
|
54
|
+
}
|
|
55
|
+
return (await this.handle(method, url.pathname, init)) ?? json(404, error("NOT_FOUND", 404));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
private async handle(method: string, path: string, init: RequestInit): Promise<Reply> {
|
|
59
|
+
const [, , , , id, action, number] = path.split("/");
|
|
60
|
+
if (method === "POST" && path === "/api/files/uploads") {
|
|
61
|
+
const body = JSON.parse(String(init.body)) as { size: number };
|
|
62
|
+
const session: Session = {
|
|
63
|
+
id: `u${this.nextId++}`,
|
|
64
|
+
size: body.size,
|
|
65
|
+
partCount: Math.max(1, Math.ceil(body.size / PART)),
|
|
66
|
+
parts: new Map(),
|
|
67
|
+
status: "open",
|
|
68
|
+
};
|
|
69
|
+
this.sessions.set(session.id, session);
|
|
70
|
+
return json(201, { id: session.id, partSize: PART, partCount: session.partCount, expiresAt: "2026-01-02T00:00:00.000Z" });
|
|
71
|
+
}
|
|
72
|
+
const session = this.sessions.get(id ?? "");
|
|
73
|
+
if (!session) return json(404, error("UPLOAD_NOT_FOUND", 404));
|
|
74
|
+
if (method === "GET" && action === undefined) {
|
|
75
|
+
return json(200, {
|
|
76
|
+
id: session.id,
|
|
77
|
+
status: session.status,
|
|
78
|
+
size: session.size,
|
|
79
|
+
partSize: PART,
|
|
80
|
+
partCount: session.partCount,
|
|
81
|
+
receivedParts: [...session.parts.keys()].sort((left, right) => left - right),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (method === "PUT" && action === "parts") {
|
|
85
|
+
if (session.status === "aborted") return json(410, error("UPLOAD_ABORTED", 410));
|
|
86
|
+
const headers = new Headers(init.headers);
|
|
87
|
+
expect(headers.get("content-type")).toBe("application/octet-stream");
|
|
88
|
+
this.inFlight += 1;
|
|
89
|
+
this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
|
|
90
|
+
try {
|
|
91
|
+
await this.gate;
|
|
92
|
+
if (init.signal?.aborted) throw init.signal.reason;
|
|
93
|
+
const bytes = await (init.body as Blob).arrayBuffer();
|
|
94
|
+
session.parts.set(Number(number), bytes.byteLength);
|
|
95
|
+
return json(200, { partNumber: Number(number), size: bytes.byteLength });
|
|
96
|
+
} finally {
|
|
97
|
+
this.inFlight -= 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (method === "POST" && action === "complete") {
|
|
101
|
+
expect(new Headers(init.headers).get("content-type")).toBe("application/json");
|
|
102
|
+
if (session.parts.size < session.partCount) return json(409, error("UPLOAD_INCOMPLETE", 409));
|
|
103
|
+
session.status = "completed";
|
|
104
|
+
return json(200, { id: session.id, status: "completed", key: `uploads/${session.id}`, size: session.size, contentType: null, url: "/files/x" });
|
|
105
|
+
}
|
|
106
|
+
if (method === "DELETE" && action === undefined) {
|
|
107
|
+
session.status = "aborted";
|
|
108
|
+
return new Response(null, { status: 204 });
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
115
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function error(code: string, statusCode: number): unknown {
|
|
119
|
+
return { success: false, error: { code, message: code, statusCode, path: "/", timestamp: "" } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function file(size: number): File {
|
|
123
|
+
return new File([new Uint8Array(size).fill(7)], "data.bin", { type: "application/octet-stream" });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setup(): { server: FakeUploads; client: ReturnType<typeof createApiClient> } {
|
|
127
|
+
const server = new FakeUploads();
|
|
128
|
+
const fetch: typeof globalThis.fetch = Object.assign(server.fetch, { preconnect: globalThis.fetch.preconnect });
|
|
129
|
+
return { server, client: createApiClient({ fetch }) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A browser XMLHttpRequest backed by the fake server. Like a browser, it
|
|
134
|
+
* reports the body as sent, in two steps, before the server answers, so a
|
|
135
|
+
* failed attempt leaves bytes that were reported but never stored.
|
|
136
|
+
*/
|
|
137
|
+
class FakeXhr {
|
|
138
|
+
static server: FakeUploads;
|
|
139
|
+
static readonly requests: FakeXhr[] = [];
|
|
140
|
+
readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null };
|
|
141
|
+
onload: (() => void) | null = null;
|
|
142
|
+
onerror: (() => void) | null = null;
|
|
143
|
+
onabort: (() => void) | null = null;
|
|
144
|
+
withCredentials = false;
|
|
145
|
+
status = 0;
|
|
146
|
+
statusText = "";
|
|
147
|
+
responseText = "";
|
|
148
|
+
method = "";
|
|
149
|
+
url = "";
|
|
150
|
+
aborted = false;
|
|
151
|
+
readonly headers = new Headers();
|
|
152
|
+
private responseHeaders = new Headers();
|
|
153
|
+
private readonly controller = new AbortController();
|
|
154
|
+
|
|
155
|
+
constructor() {
|
|
156
|
+
FakeXhr.requests.push(this);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
open(method: string, url: string): void {
|
|
160
|
+
this.method = method;
|
|
161
|
+
this.url = url;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
setRequestHeader(name: string, value: string): void {
|
|
165
|
+
this.headers.set(name, value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getResponseHeader(name: string): string | null {
|
|
169
|
+
return this.responseHeaders.get(name);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
abort(): void {
|
|
173
|
+
this.aborted = true;
|
|
174
|
+
this.controller.abort(new DOMException("Aborted", "AbortError"));
|
|
175
|
+
queueMicrotask(() => this.onabort?.());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
send(body: Blob): void {
|
|
179
|
+
void this.exchange(body);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private progress(loaded: number, total: number): void {
|
|
183
|
+
this.upload.onprogress?.({ loaded, total, lengthComputable: true } as ProgressEvent);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async exchange(body: Blob): Promise<void> {
|
|
187
|
+
for (const loaded of [Math.floor(body.size / 2), body.size]) {
|
|
188
|
+
await Promise.resolve();
|
|
189
|
+
if (this.aborted) return;
|
|
190
|
+
this.progress(loaded, body.size);
|
|
191
|
+
}
|
|
192
|
+
let response: Response;
|
|
193
|
+
try {
|
|
194
|
+
response = await FakeXhr.server.fetch(this.url, {
|
|
195
|
+
method: this.method,
|
|
196
|
+
headers: this.headers,
|
|
197
|
+
body,
|
|
198
|
+
signal: this.controller.signal,
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
if (!this.aborted) this.onerror?.();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (this.aborted) return;
|
|
205
|
+
this.status = response.status;
|
|
206
|
+
this.statusText = response.statusText;
|
|
207
|
+
this.responseHeaders = response.headers;
|
|
208
|
+
this.responseText = await response.text();
|
|
209
|
+
this.onload?.();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A browser: XMLHttpRequest and the default client both reach a fresh fake server. */
|
|
214
|
+
function browser(): FakeUploads {
|
|
215
|
+
const server = new FakeUploads();
|
|
216
|
+
FakeXhr.server = server;
|
|
217
|
+
FakeXhr.requests.length = 0;
|
|
218
|
+
defaultClient.fetch = server.fetch;
|
|
219
|
+
vi.stubGlobal("XMLHttpRequest", FakeXhr);
|
|
220
|
+
return server;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
afterEach(() => {
|
|
224
|
+
vi.useRealTimers();
|
|
225
|
+
vi.unstubAllGlobals();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("uploadFile", () => {
|
|
229
|
+
it("sends every part with bounded concurrency, reports progress, and completes", async () => {
|
|
230
|
+
const { server, client } = setup();
|
|
231
|
+
let release = (): void => undefined;
|
|
232
|
+
server.gate = new Promise((resolve) => { release = resolve; });
|
|
233
|
+
const progress: Array<[number, number]> = [];
|
|
234
|
+
const sessions: UploadSession[] = [];
|
|
235
|
+
|
|
236
|
+
const done = uploadFile(file(95), {
|
|
237
|
+
client,
|
|
238
|
+
concurrency: 3,
|
|
239
|
+
onProgress: (sent, total) => progress.push([sent, total]),
|
|
240
|
+
onSession: (session) => sessions.push(session),
|
|
241
|
+
});
|
|
242
|
+
await vi.waitFor(() => expect(server.inFlight).toBe(3));
|
|
243
|
+
release();
|
|
244
|
+
const result = await done;
|
|
245
|
+
|
|
246
|
+
expect(result).toMatchObject({ status: "completed", size: 95, key: "uploads/u1" });
|
|
247
|
+
expect(sessions).toEqual([expect.objectContaining({ id: "u1", partCount: 10 })]);
|
|
248
|
+
expect(server.maxInFlight).toBe(3);
|
|
249
|
+
expect(server.sessions.get("u1")?.parts.get(10)).toBe(5);
|
|
250
|
+
expect(progress[0]).toEqual([0, 95]);
|
|
251
|
+
expect(progress.at(-1)).toEqual([95, 95]);
|
|
252
|
+
expect(progress.map(([sent]) => sent)).toEqual([...progress.map(([sent]) => sent)].sort((a, b) => a - b));
|
|
253
|
+
expect(server.log.filter((route) => route.startsWith("POST") && route.endsWith("/complete"))).toHaveLength(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("retries a failed part after the Retry-After delay and after network errors", async () => {
|
|
257
|
+
vi.useFakeTimers();
|
|
258
|
+
const { server, client } = setup();
|
|
259
|
+
server.overrides.push({ match: "/parts/2", reply: () => json(429, error("RATE_LIMIT_EXCEEDED", 429), { "retry-after": "7" }) });
|
|
260
|
+
server.overrides.push({
|
|
261
|
+
match: "/parts/3",
|
|
262
|
+
reply: () => {
|
|
263
|
+
throw new TypeError("Failed to fetch");
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
const done = uploadFile(file(30), { client, concurrency: 1, retryDelayMs: 100 });
|
|
267
|
+
|
|
268
|
+
await vi.advanceTimersByTimeAsync(6_999);
|
|
269
|
+
expect(server.sessions.get("u1")?.parts.has(2)).toBe(false);
|
|
270
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
271
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
272
|
+
const result = await done;
|
|
273
|
+
expect(result.status).toBe("completed");
|
|
274
|
+
expect(server.log.filter((route) => route.endsWith("/parts/2"))).toHaveLength(2);
|
|
275
|
+
expect(server.log.filter((route) => route.endsWith("/parts/3"))).toHaveLength(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("retries a gateway's non-JSON 502 page", async () => {
|
|
279
|
+
const { server, client } = setup();
|
|
280
|
+
server.overrides.push({ match: "/parts/1", reply: () => new Response("<html>Bad Gateway</html>", { status: 502 }) });
|
|
281
|
+
expect((await uploadFile(file(10), { client, retryDelayMs: 0 })).status).toBe("completed");
|
|
282
|
+
expect(server.log.filter((route) => route.endsWith("/parts/1"))).toHaveLength(2);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("does not retry a request the server rejected and stops after the retry budget", async () => {
|
|
286
|
+
const { server, client } = setup();
|
|
287
|
+
server.overrides.push({ match: "/parts/1", reply: () => json(400, error("UPLOAD_PART_SIZE_MISMATCH", 400)) });
|
|
288
|
+
await expect(uploadFile(file(10), { client })).rejects.toMatchObject({ code: "UPLOAD_PART_SIZE_MISMATCH" });
|
|
289
|
+
expect(server.log.filter((route) => route.endsWith("/parts/1"))).toHaveLength(1);
|
|
290
|
+
|
|
291
|
+
for (let index = 0; index < 3; index++) {
|
|
292
|
+
server.overrides.push({ match: "/parts/1", reply: () => json(503, error("UNAVAILABLE", 503)) });
|
|
293
|
+
}
|
|
294
|
+
await expect(uploadFile(file(10), { client, retries: 2, retryDelayMs: 0 })).rejects.toMatchObject({ statusCode: 503 });
|
|
295
|
+
expect(server.log.filter((route) => route === "PUT /api/files/uploads/u2/parts/1")).toHaveLength(3);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("cancels the upload on the server when the signal aborts", async () => {
|
|
299
|
+
const { server, client } = setup();
|
|
300
|
+
const controller = new AbortController();
|
|
301
|
+
let release = (): void => undefined;
|
|
302
|
+
server.gate = new Promise((resolve) => { release = resolve; });
|
|
303
|
+
const done = uploadFile(file(50), { client, signal: controller.signal });
|
|
304
|
+
await vi.waitFor(() => expect(server.inFlight).toBeGreaterThan(0));
|
|
305
|
+
controller.abort();
|
|
306
|
+
release();
|
|
307
|
+
|
|
308
|
+
await expect(done).rejects.toMatchObject({ name: "AbortError" });
|
|
309
|
+
expect(server.log).toContain("DELETE /api/files/uploads/u1");
|
|
310
|
+
expect(server.sessions.get("u1")?.status).toBe("aborted");
|
|
311
|
+
expect(server.log.some((route) => route.endsWith("/complete"))).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("resends missing parts once when completion reports the upload incomplete", async () => {
|
|
315
|
+
const { server, client } = setup();
|
|
316
|
+
server.overrides.push({ match: "/parts/2", reply: () => json(200, { partNumber: 2, size: PART }) });
|
|
317
|
+
const result = await uploadFile(file(30), { client });
|
|
318
|
+
expect(result.status).toBe("completed");
|
|
319
|
+
expect(server.log.filter((route) => route.endsWith("/parts/2"))).toHaveLength(2);
|
|
320
|
+
expect(server.log.filter((route) => route.endsWith("/complete"))).toHaveLength(2);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("uploadFile in a browser", () => {
|
|
325
|
+
it("sends parts with XMLHttpRequest and reports bytes as they are sent", async () => {
|
|
326
|
+
const server = browser();
|
|
327
|
+
const progress: number[] = [];
|
|
328
|
+
|
|
329
|
+
const result = await uploadFile(file(25), {
|
|
330
|
+
concurrency: 1,
|
|
331
|
+
onProgress: (sent, total) => {
|
|
332
|
+
expect(total).toBe(25);
|
|
333
|
+
progress.push(sent);
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(result).toMatchObject({ status: "completed", size: 25 });
|
|
338
|
+
expect(progress).toEqual([0, 5, 10, 15, 20, 22, 25]);
|
|
339
|
+
expect(FakeXhr.requests.map((request) => `${request.method} ${request.url}`)).toEqual([
|
|
340
|
+
"PUT /api/files/uploads/u1/parts/1",
|
|
341
|
+
"PUT /api/files/uploads/u1/parts/2",
|
|
342
|
+
"PUT /api/files/uploads/u1/parts/3",
|
|
343
|
+
]);
|
|
344
|
+
for (const request of FakeXhr.requests) {
|
|
345
|
+
expect(request.withCredentials).toBe(false);
|
|
346
|
+
expect(request.headers.get("content-type")).toBe("application/octet-stream");
|
|
347
|
+
}
|
|
348
|
+
expect(server.log.filter((route) => route.startsWith("POST"))).toEqual([
|
|
349
|
+
"POST /api/files/uploads",
|
|
350
|
+
"POST /api/files/uploads/u1/complete",
|
|
351
|
+
]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("drops a failed part's bytes and retries it after Retry-After without going backwards", async () => {
|
|
355
|
+
vi.useFakeTimers();
|
|
356
|
+
const server = browser();
|
|
357
|
+
server.overrides.push({ match: "/parts/1", reply: () => json(429, error("RATE_LIMIT_EXCEEDED", 429), { "retry-after": "7" }) });
|
|
358
|
+
const progress: number[] = [];
|
|
359
|
+
const done = uploadFile(file(30), { concurrency: 2, onProgress: (sent) => progress.push(sent) });
|
|
360
|
+
|
|
361
|
+
await vi.advanceTimersByTimeAsync(6_999);
|
|
362
|
+
// Parts 2 and 3 are stored; the bytes part 1 reported before its 429 are no longer counted.
|
|
363
|
+
expect([...(server.sessions.get("u1")?.parts.keys() ?? [])].sort()).toEqual([2, 3]);
|
|
364
|
+
expect(progress.at(-1)).toBe(20);
|
|
365
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
366
|
+
const result = await done;
|
|
367
|
+
|
|
368
|
+
expect(result.status).toBe("completed");
|
|
369
|
+
expect(server.log.filter((route) => route.endsWith("/parts/1"))).toHaveLength(2);
|
|
370
|
+
expect(progress.at(-1)).toBe(30);
|
|
371
|
+
expect(progress).toEqual([...new Set(progress)].sort((left, right) => left - right));
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("retries a network error and reads the error envelope of a rejected part", async () => {
|
|
375
|
+
const server = browser();
|
|
376
|
+
server.overrides.push({
|
|
377
|
+
match: "/parts/1",
|
|
378
|
+
reply: () => {
|
|
379
|
+
throw new TypeError("Failed to fetch");
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
expect((await uploadFile(file(10), { retryDelayMs: 0 })).status).toBe("completed");
|
|
383
|
+
expect(server.log.filter((route) => route.endsWith("/parts/1"))).toHaveLength(2);
|
|
384
|
+
|
|
385
|
+
server.overrides.push({ match: "/parts/1", reply: () => json(400, error("UPLOAD_PART_SIZE_MISMATCH", 400)) });
|
|
386
|
+
await expect(uploadFile(file(10))).rejects.toMatchObject({
|
|
387
|
+
name: "ApiError",
|
|
388
|
+
code: "UPLOAD_PART_SIZE_MISMATCH",
|
|
389
|
+
statusCode: 400,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("aborts the request in flight and cancels the upload", async () => {
|
|
394
|
+
const server = browser();
|
|
395
|
+
const controller = new AbortController();
|
|
396
|
+
let release = (): void => undefined;
|
|
397
|
+
server.gate = new Promise((resolve) => { release = resolve; });
|
|
398
|
+
const done = uploadFile(file(50), { signal: controller.signal });
|
|
399
|
+
await vi.waitFor(() => expect(server.inFlight).toBe(3));
|
|
400
|
+
controller.abort();
|
|
401
|
+
release();
|
|
402
|
+
|
|
403
|
+
await expect(done).rejects.toMatchObject({ name: "AbortError" });
|
|
404
|
+
expect(FakeXhr.requests).toHaveLength(3);
|
|
405
|
+
expect(FakeXhr.requests.every((request) => request.aborted)).toBe(true);
|
|
406
|
+
expect(server.log).toContain("DELETE /api/files/uploads/u1");
|
|
407
|
+
expect(server.sessions.get("u1")?.status).toBe("aborted");
|
|
408
|
+
expect(server.log.some((route) => route.endsWith("/complete"))).toBe(false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("sends parts with fetch when a custom client is given", async () => {
|
|
412
|
+
const server = browser();
|
|
413
|
+
const { server: other, client } = setup();
|
|
414
|
+
expect((await uploadFile(file(20), { client })).status).toBe("completed");
|
|
415
|
+
expect(FakeXhr.requests).toHaveLength(0);
|
|
416
|
+
expect(server.log).toHaveLength(0);
|
|
417
|
+
expect(other.log.filter((route) => route.startsWith("PUT"))).toHaveLength(2);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("resumeUpload", () => {
|
|
422
|
+
it("sends only the parts the server lacks and counts stored ones as progress", async () => {
|
|
423
|
+
const { server, client } = setup();
|
|
424
|
+
const data = file(45);
|
|
425
|
+
const controller = new AbortController();
|
|
426
|
+
server.overrides.push({ match: "/parts/3", reply: () => {
|
|
427
|
+
controller.abort();
|
|
428
|
+
throw new DOMException("Aborted", "AbortError");
|
|
429
|
+
} });
|
|
430
|
+
await expect(uploadFile(data, { client, concurrency: 1, signal: controller.signal })).rejects.toBeDefined();
|
|
431
|
+
// The first attempt was cancelled; start again and drop part 3 to resume.
|
|
432
|
+
const session = server.sessions.get("u1");
|
|
433
|
+
if (!session) throw new Error("no session");
|
|
434
|
+
session.status = "open";
|
|
435
|
+
session.parts.delete(3);
|
|
436
|
+
server.log.length = 0;
|
|
437
|
+
|
|
438
|
+
const progress: number[] = [];
|
|
439
|
+
const result = await resumeUpload("u1", data, { client, onProgress: (sent) => progress.push(sent) });
|
|
440
|
+
expect(result.status).toBe("completed");
|
|
441
|
+
expect(server.log.filter((route) => route.startsWith("PUT")).sort()).toEqual([
|
|
442
|
+
"PUT /api/files/uploads/u1/parts/3",
|
|
443
|
+
"PUT /api/files/uploads/u1/parts/4",
|
|
444
|
+
"PUT /api/files/uploads/u1/parts/5",
|
|
445
|
+
]);
|
|
446
|
+
expect(progress[0]).toBe(20);
|
|
447
|
+
expect(progress.at(-1)).toBe(45);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("refuses a file of another size", async () => {
|
|
451
|
+
const { server, client } = setup();
|
|
452
|
+
const { id } = await client.post<UploadSession>("/files/uploads", { filename: "a", size: 20 });
|
|
453
|
+
await expect(resumeUpload(id, file(21), { client })).rejects.toThrow("expects 20");
|
|
454
|
+
expect(server.log.some((route) => route.startsWith("PUT"))).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
});
|