@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.
Files changed (154) hide show
  1. package/README.md +1 -1
  2. package/dist/add.d.ts +10 -1
  3. package/dist/add.js +20 -7
  4. package/dist/api-paths.d.ts +34 -0
  5. package/dist/api-paths.js +103 -0
  6. package/dist/deploy-compose-profile.d.ts +4 -0
  7. package/dist/deploy-compose-profile.js +13 -2
  8. package/dist/deploy-compose-render.d.ts +1 -1
  9. package/dist/deploy-compose-render.js +23 -7
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.d.ts +3 -0
  12. package/dist/deploy-profile.js +12 -4
  13. package/dist/deploy-render.js +54 -8
  14. package/dist/deploy.js +21 -1
  15. package/dist/dev.d.ts +14 -0
  16. package/dist/dev.js +152 -10
  17. package/dist/index.js +15 -2
  18. package/dist/lib.d.ts +2 -0
  19. package/dist/lib.js +4 -2
  20. package/dist/lockfile.d.ts +3 -0
  21. package/dist/lockfile.js +11 -0
  22. package/dist/module-api-paths.d.ts +52 -0
  23. package/dist/module-api-paths.js +189 -0
  24. package/dist/remove.d.ts +3 -0
  25. package/dist/remove.js +12 -3
  26. package/dist/templates/fullstack/AGENTS.md +27 -5
  27. package/dist/templates/fullstack/README.md +28 -11
  28. package/dist/templates/fullstack/apps/api/package.json +2 -2
  29. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
  30. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  31. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  32. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  33. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  35. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  37. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  39. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  40. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  41. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  42. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
  43. package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
  44. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  45. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  46. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
  47. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  49. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  50. package/dist/templates/fullstack/apps/web/components.json +3 -1
  51. package/dist/templates/fullstack/apps/web/package.json +1 -1
  52. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  75. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  76. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  77. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  78. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  79. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  80. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  87. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  88. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  89. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  90. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  91. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  93. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  94. package/dist/templates/modules/auth/module.manifest.json +0 -1
  95. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  96. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  97. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  98. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  99. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  100. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  101. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  102. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  103. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  104. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  105. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  106. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  107. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  108. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  109. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  110. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  111. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  112. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  113. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
  114. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  115. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  116. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  117. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  118. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  119. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  120. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  121. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  122. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  124. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  125. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  126. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  127. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  128. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  129. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  130. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  131. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  132. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  133. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  134. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  135. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  136. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  137. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  138. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  139. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  140. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  141. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  142. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  143. package/dist/templates/modules/redis/module.manifest.json +4 -0
  144. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  145. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  146. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  147. package/dist/templates/todo/AGENTS.md +19 -4
  148. package/dist/templates/todo/README.md +27 -12
  149. package/dist/update.d.ts +5 -0
  150. package/dist/update.js +35 -0
  151. package/package.json +2 -2
  152. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
  153. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  154. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -0,0 +1,356 @@
1
+ import { ApiError, type ApiClient } from "@podosoft/podokit-api-client";
2
+ import { api } from "./api";
3
+
4
+ // Resumable uploads through the API's /files/uploads routes (file-upload module).
5
+ // Parts go to the same-origin /api/files/uploads path. The module declares it as
6
+ // a direct API path, so `podo add` lists it in the gateway's apiPaths and parts
7
+ // larger than the web server's BODY_SIZE_LIMIT reach the API.
8
+
9
+ export interface UploadSession {
10
+ id: string;
11
+ partSize: number;
12
+ partCount: number;
13
+ expiresAt: string;
14
+ }
15
+
16
+ export type UploadState = "open" | "completing" | "completed" | "aborted";
17
+
18
+ export interface UploadStatus extends UploadSession {
19
+ status: UploadState;
20
+ filename: string;
21
+ contentType: string | null;
22
+ size: number;
23
+ receivedParts: number[];
24
+ key?: string;
25
+ }
26
+
27
+ export interface UploadedFile {
28
+ id: string;
29
+ status: "completed";
30
+ key: string;
31
+ size: number;
32
+ contentType: string | null;
33
+ url: string;
34
+ }
35
+
36
+ export interface UploadOptions {
37
+ /**
38
+ * Called with the bytes sent so far. With the default client in a browser it
39
+ * reports bytes as they leave; otherwise it reports when a part is stored.
40
+ * The value never decreases: when a part fails, its bytes stop counting and
41
+ * the value holds until the retry passes it.
42
+ */
43
+ onProgress?: (sentBytes: number, totalBytes: number) => void;
44
+ /** Aborting stops the transfer and cancels the upload on the server. */
45
+ signal?: AbortSignal;
46
+ /** Parts in flight at once. Defaults to 3. */
47
+ concurrency?: number;
48
+ /**
49
+ * Further attempts for a request that fails with a network error, 408, 425,
50
+ * 429, or 5xx. Defaults to 5.
51
+ */
52
+ retries?: number;
53
+ /** First retry delay in milliseconds, doubled per attempt up to 30 s. A Retry-After header wins. Defaults to 500. */
54
+ retryDelayMs?: number;
55
+ /**
56
+ * Defaults to the app's same-origin client, which sends parts with
57
+ * XMLHttpRequest in a browser. A custom client sends parts with its fetch.
58
+ */
59
+ client?: ApiClient;
60
+ }
61
+
62
+ export interface NewUploadOptions extends UploadOptions {
63
+ /** Defaults to the File's name. */
64
+ filename?: string;
65
+ /** Defaults to the Blob's type. */
66
+ contentType?: string;
67
+ /** Receives the session as soon as it exists; keep its id to resume after a reload. */
68
+ onSession?: (session: UploadSession) => void;
69
+ }
70
+
71
+ const BASE = "/files/uploads";
72
+ /** The REST prefix of the default same-origin client in ./api. */
73
+ const API_PREFIX = "/api";
74
+ const MAX_DELAY_MS = 30_000;
75
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
76
+
77
+ function abortReason(signal: AbortSignal): unknown {
78
+ return signal.reason ?? new DOMException("The upload was aborted", "AbortError");
79
+ }
80
+
81
+ function throwIfAborted(signal: AbortSignal | undefined): void {
82
+ if (signal?.aborted) throw abortReason(signal);
83
+ }
84
+
85
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
86
+ return new Promise((resolve, reject) => {
87
+ if (signal?.aborted) {
88
+ reject(abortReason(signal));
89
+ return;
90
+ }
91
+ const onAbort = (): void => {
92
+ clearTimeout(timer);
93
+ reject(signal ? abortReason(signal) : new DOMException("Aborted", "AbortError"));
94
+ };
95
+ const timer = setTimeout(() => {
96
+ signal?.removeEventListener("abort", onAbort);
97
+ resolve();
98
+ }, ms);
99
+ signal?.addEventListener("abort", onAbort, { once: true });
100
+ });
101
+ }
102
+
103
+ /** Network failures surface from fetch, and from sendPart, as a TypeError. */
104
+ function transient(error: unknown): boolean {
105
+ return error instanceof ApiError ? RETRYABLE_STATUS.has(error.statusCode) : error instanceof TypeError;
106
+ }
107
+
108
+ /** Reads Retry-After as delay seconds or an HTTP date, as the API client does. */
109
+ function retryAfterSeconds(value: string | null): number | undefined {
110
+ const header = value?.trim();
111
+ if (!header) return undefined;
112
+ if (/^\d+$/.test(header)) return Number(header);
113
+ const date = Date.parse(header);
114
+ return Number.isNaN(date) ? undefined : Math.max(0, Math.ceil((date - Date.now()) / 1000));
115
+ }
116
+
117
+ /** The API client's error for a failed response: the standard envelope, or HTTP_ERROR for text bodies. */
118
+ function responseError(request: XMLHttpRequest): ApiError {
119
+ const retryAfter = retryAfterSeconds(request.getResponseHeader("retry-after"));
120
+ let body: unknown;
121
+ try {
122
+ body = request.responseText ? JSON.parse(request.responseText) : undefined;
123
+ } catch {
124
+ body = undefined;
125
+ }
126
+ const error = body && typeof body === "object" && "error" in body ? body.error : undefined;
127
+ if (error && typeof error === "object") {
128
+ const record = error as Record<string, unknown>;
129
+ return new ApiError(
130
+ typeof record.code === "string" ? record.code : "HTTP_ERROR",
131
+ typeof record.message === "string" ? record.message : request.statusText,
132
+ typeof record.statusCode === "number" ? record.statusCode : request.status,
133
+ record.details,
134
+ retryAfter,
135
+ );
136
+ }
137
+ return new ApiError("HTTP_ERROR", request.statusText || "Request failed", request.status, undefined, retryAfter);
138
+ }
139
+
140
+ /**
141
+ * PUTs one part with XMLHttpRequest, whose upload progress events report bytes
142
+ * as the browser sends them; fetch has no upload progress.
143
+ */
144
+ function sendPart(
145
+ url: string,
146
+ part: Blob,
147
+ signal: AbortSignal | undefined,
148
+ onBytes: (loaded: number) => void,
149
+ ): Promise<void> {
150
+ return new Promise((resolve, reject) => {
151
+ if (signal?.aborted) {
152
+ reject(abortReason(signal));
153
+ return;
154
+ }
155
+ const request = new XMLHttpRequest();
156
+ const onAbort = (): void => request.abort();
157
+ const settle = (outcome: () => void): void => {
158
+ signal?.removeEventListener("abort", onAbort);
159
+ outcome();
160
+ };
161
+ request.open("PUT", url);
162
+ // Same-origin requests carry the session cookie without withCredentials.
163
+ request.withCredentials = false;
164
+ request.setRequestHeader("content-type", "application/octet-stream");
165
+ request.upload.onprogress = (event) => onBytes(Math.min(event.loaded, part.size));
166
+ request.onload = () =>
167
+ settle(() => (request.status >= 200 && request.status < 300 ? resolve() : reject(responseError(request))));
168
+ request.onerror = () => settle(() => reject(new TypeError("Network request failed")));
169
+ request.onabort = () =>
170
+ settle(() => reject(signal?.aborted ? abortReason(signal) : new DOMException("Aborted", "AbortError")));
171
+ signal?.addEventListener("abort", onAbort, { once: true });
172
+ request.send(part);
173
+ });
174
+ }
175
+
176
+ class Transfer {
177
+ private readonly client: ApiClient;
178
+ private readonly signal?: AbortSignal;
179
+ private readonly retries: number;
180
+ private readonly retryDelayMs: number;
181
+ private readonly concurrency: number;
182
+ /** Parts go through XMLHttpRequest only for the default client, whose URLs this file knows. */
183
+ private readonly xhr: boolean;
184
+
185
+ constructor(private readonly options: UploadOptions) {
186
+ this.client = options.client ?? api;
187
+ this.xhr = options.client === undefined && typeof XMLHttpRequest !== "undefined";
188
+ this.signal = options.signal;
189
+ this.retries = Math.max(0, options.retries ?? 5);
190
+ this.retryDelayMs = Math.max(0, options.retryDelayMs ?? 500);
191
+ this.concurrency = Math.max(1, Math.floor(options.concurrency ?? 3));
192
+ }
193
+
194
+ async attempt<T>(request: () => Promise<T>, retryable: (error: unknown) => boolean = transient): Promise<T> {
195
+ for (let attempt = 0; ; attempt++) {
196
+ throwIfAborted(this.signal);
197
+ try {
198
+ return await request();
199
+ } catch (error) {
200
+ throwIfAborted(this.signal);
201
+ if (attempt >= this.retries || !retryable(error)) throw error;
202
+ const retryAfter = error instanceof ApiError ? error.retryAfterSeconds : undefined;
203
+ const delay = retryAfter !== undefined
204
+ ? retryAfter * 1000
205
+ : Math.min(MAX_DELAY_MS, this.retryDelayMs * 2 ** attempt);
206
+ await sleep(delay, this.signal);
207
+ }
208
+ }
209
+ }
210
+
211
+ create(body: { filename: string; size: number; contentType?: string }): Promise<UploadSession> {
212
+ return this.attempt(() => this.client.post<UploadSession>(BASE, body, { signal: this.signal }));
213
+ }
214
+
215
+ status(id: string): Promise<UploadStatus> {
216
+ return this.attempt(() => this.client.get<UploadStatus>(`${BASE}/${id}`, { signal: this.signal }));
217
+ }
218
+
219
+ /** Runs the transfer; when the caller aborts, cancels the upload and rethrows the abort reason. */
220
+ async cancelOnAbort(id: string, transfer: () => Promise<UploadedFile>): Promise<UploadedFile> {
221
+ try {
222
+ return await transfer();
223
+ } catch (error) {
224
+ if (!this.signal?.aborted) throw error;
225
+ // Sent without the caller's signal, which has just aborted.
226
+ await this.client.del<void>(`${BASE}/${id}`).catch(() => undefined);
227
+ throw abortReason(this.signal);
228
+ }
229
+ }
230
+
231
+ private putPart(sessionId: string, partNumber: number, part: Blob, onBytes: (loaded: number) => void): Promise<void> {
232
+ const path = `${BASE}/${sessionId}/parts/${partNumber}`;
233
+ if (this.xhr) return sendPart(`${API_PREFIX}${path}`, part, this.signal, onBytes);
234
+ return this.client
235
+ .put(path, part, { contentType: "application/octet-stream", signal: this.signal })
236
+ .then(() => undefined);
237
+ }
238
+
239
+ /** Sends the parts not yet stored, then completes the upload. */
240
+ async run(session: UploadSession, file: Blob, stored: Iterable<number>): Promise<UploadedFile> {
241
+ const size = (partNumber: number): number =>
242
+ partNumber < session.partCount ? session.partSize : file.size - session.partSize * (session.partCount - 1);
243
+ const all = Array.from({ length: session.partCount }, (_, index) => index + 1);
244
+ const received = new Set(stored);
245
+ let sent = 0;
246
+ for (const partNumber of received) sent += size(partNumber);
247
+ // Bytes of each part's current attempt. A failed attempt's entry is dropped,
248
+ // so its retry starts from zero instead of counting those bytes twice, and
249
+ // the reported value holds until the retry passes it.
250
+ const inFlight = new Map<number, number>();
251
+ let reported = -1;
252
+ const report = (): void => {
253
+ let bytes = sent;
254
+ for (const loaded of inFlight.values()) bytes += loaded;
255
+ if (bytes <= reported) return;
256
+ reported = bytes;
257
+ this.options.onProgress?.(bytes, file.size);
258
+ };
259
+ report();
260
+
261
+ const send = async (numbers: number[]): Promise<void> => {
262
+ const queue = [...numbers];
263
+ let failure: { error: unknown } | undefined;
264
+ const worker = async (): Promise<void> => {
265
+ for (let partNumber = queue.shift(); partNumber !== undefined && !failure; partNumber = queue.shift()) {
266
+ const start = (partNumber - 1) * session.partSize;
267
+ const part = file.slice(start, start + size(partNumber));
268
+ const number = partNumber;
269
+ try {
270
+ await this.attempt(async () => {
271
+ try {
272
+ await this.putPart(session.id, number, part, (loaded) => {
273
+ // A part sent again after completion reported it missing is already counted.
274
+ if (received.has(number)) return;
275
+ inFlight.set(number, loaded);
276
+ report();
277
+ });
278
+ } finally {
279
+ inFlight.delete(number);
280
+ }
281
+ });
282
+ } catch (error) {
283
+ failure ??= { error };
284
+ return;
285
+ }
286
+ if (!received.has(number)) {
287
+ received.add(number);
288
+ sent += part.size;
289
+ }
290
+ report();
291
+ }
292
+ };
293
+ // Every worker settles before a failure is reported, so no part is still
294
+ // in flight when the caller cancels the upload.
295
+ await Promise.all(Array.from({ length: Math.min(this.concurrency, numbers.length) }, worker));
296
+ if (failure) throw failure.error;
297
+ };
298
+
299
+ await send(all.filter((partNumber) => !received.has(partNumber)));
300
+ for (let round = 0; ; round++) {
301
+ try {
302
+ return await this.attempt(
303
+ () => this.client.post<UploadedFile>(`${BASE}/${session.id}/complete`, {}, { signal: this.signal }),
304
+ (error) => transient(error) || (error instanceof ApiError && error.code === "UPLOAD_COMPLETING"),
305
+ );
306
+ } catch (error) {
307
+ if (round > 0 || !(error instanceof ApiError)) throw error;
308
+ // The server is the source of truth: resend what it lacks, once.
309
+ if (error.code === "UPLOAD_INCOMPLETE") {
310
+ const stored = new Set((await this.status(session.id)).receivedParts);
311
+ await send(all.filter((partNumber) => !stored.has(partNumber)));
312
+ } else if (error.code === "UPLOAD_INVALID_PART") {
313
+ await send(all);
314
+ } else {
315
+ throw error;
316
+ }
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Uploads a file in parts and returns the stored file. Failed parts are
324
+ * retried with backoff; aborting `signal` cancels the upload on the server.
325
+ */
326
+ export async function uploadFile(file: Blob, options: NewUploadOptions = {}): Promise<UploadedFile> {
327
+ const transfer = new Transfer(options);
328
+ const named = typeof File !== "undefined" && file instanceof File && file.name !== "";
329
+ const filename = options.filename ?? (named ? (file as File).name : "upload");
330
+ const contentType = options.contentType ?? (file.type || undefined);
331
+ const session = await transfer.create({
332
+ filename,
333
+ size: file.size,
334
+ ...(contentType ? { contentType } : {}),
335
+ });
336
+ options.onSession?.(session);
337
+ return transfer.cancelOnAbort(session.id, () => transfer.run(session, file, []));
338
+ }
339
+
340
+ /**
341
+ * Continues an upload started earlier, for example before a page reload, with
342
+ * the same file: only the parts the server has not stored are sent.
343
+ */
344
+ export async function resumeUpload(id: string, file: Blob, options: UploadOptions = {}): Promise<UploadedFile> {
345
+ const transfer = new Transfer(options);
346
+ const status = await transfer.status(id);
347
+ if (status.size !== file.size) {
348
+ throw new Error(`The file has ${file.size} bytes, but upload ${id} expects ${status.size}`);
349
+ }
350
+ return transfer.cancelOnAbort(id, () => transfer.run(status, file, status.receivedParts));
351
+ }
352
+
353
+ /** Cancels an upload and discards its stored parts. */
354
+ export async function cancelUpload(id: string, options: { client?: ApiClient } = {}): Promise<void> {
355
+ await (options.client ?? api).del<void>(`${BASE}/${id}`);
356
+ }
@@ -0,0 +1,153 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { expect, test, type APIRequestContext, type APIResponse, type PlaywrightWorkerArgs } from "@playwright/test";
3
+
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+ // Parts are larger than the web server's BODY_SIZE_LIMIT, so they go to the API
6
+ // the way the gateway's direct apiPaths entry sends them: E2E_API_URL when the
7
+ // harness provides it, otherwise /api on a gateway that routes the path.
8
+ const apiBase = process.env.E2E_API_URL ?? `${base}/api`;
9
+ const uploads = `${apiBase}/files/uploads`;
10
+
11
+ type Playwright = PlaywrightWorkerArgs["playwright"];
12
+
13
+ interface Created {
14
+ id: string;
15
+ partSize: number;
16
+ partCount: number;
17
+ expiresAt: string;
18
+ }
19
+
20
+ async function session(playwright: Playwright): Promise<{ ctx: APIRequestContext; userId: string | undefined }> {
21
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
22
+ const email = `chunks-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
23
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "C" } }).catch(() => undefined);
24
+ const current = await ctx.get("/api/auth/get-session");
25
+ const body = current.ok() ? await current.json() as { user?: { id?: string } } | null : null;
26
+ return { ctx, userId: body?.user?.id };
27
+ }
28
+
29
+ async function create(ctx: APIRequestContext, size: number, filename = "big.bin"): Promise<Created> {
30
+ const res = await ctx.post(uploads, { data: { filename, size, contentType: "application/octet-stream" } });
31
+ test.skip(res.status() >= 500, "selected object storage is not reachable");
32
+ expect(res.status(), await res.text()).toBe(201);
33
+ return await res.json() as Created;
34
+ }
35
+
36
+ function putPart(ctx: APIRequestContext, id: string, partNumber: number, body: Buffer): Promise<APIResponse> {
37
+ return ctx.put(`${uploads}/${id}/parts/${partNumber}`, {
38
+ data: body,
39
+ headers: { "content-type": "application/octet-stream" },
40
+ });
41
+ }
42
+
43
+ function part(file: Buffer, upload: Created, partNumber: number): Buffer {
44
+ return file.subarray((partNumber - 1) * upload.partSize, partNumber * upload.partSize);
45
+ }
46
+
47
+ function sha256(value: Buffer): string {
48
+ return createHash("sha256").update(value).digest("hex");
49
+ }
50
+
51
+ test("chunked upload: parts, a retried part, resume status, and complete store the file", async ({ playwright }) => {
52
+ const { ctx, userId } = await session(playwright);
53
+ try {
54
+ const probe = await create(ctx, 1);
55
+ expect((await ctx.delete(`${uploads}/${probe.id}`)).status()).toBe(204);
56
+ const file = randomBytes(probe.partSize + 1024 * 1024 + 123);
57
+ const upload = await create(ctx, file.byteLength);
58
+ expect(upload.partCount).toBe(2);
59
+
60
+ expect((await putPart(ctx, upload.id, 2, part(file, upload, 2))).status()).toBe(200);
61
+ const first = await putPart(ctx, upload.id, 1, part(file, upload, 1));
62
+ expect(first.status(), await first.text()).toBe(200);
63
+ expect(await first.json()).toEqual({ partNumber: 1, size: upload.partSize });
64
+ // Sending a part again (a client retry) replaces it.
65
+ expect((await putPart(ctx, upload.id, 1, part(file, upload, 1))).status()).toBe(200);
66
+
67
+ const status = await ctx.get(`${uploads}/${upload.id}`);
68
+ expect(status.status()).toBe(200);
69
+ expect(await status.json()).toMatchObject({ status: "open", size: file.byteLength, receivedParts: [1, 2] });
70
+
71
+ const done = await ctx.post(`${uploads}/${upload.id}/complete`, { data: {} });
72
+ expect(done.status(), await done.text()).toBe(200);
73
+ const result = await done.json() as { key: string; size: number; url: string; status: string };
74
+ expect(result.status).toBe("completed");
75
+ expect(result.size).toBe(file.byteLength);
76
+ if (userId) expect(result.key).toMatch(new RegExp(`^uploads/${userId}/${upload.id}-big\\.bin$`));
77
+ // Completing again returns the same result.
78
+ expect(await (await ctx.post(`${uploads}/${upload.id}/complete`, { data: {} })).json()).toMatchObject({ key: result.key });
79
+
80
+ // Local storage returns a signed API path. S3 returns a URL signed for the API's
81
+ // S3 endpoint; a containerized dev stack signs an in-network host, so download it
82
+ // only when the runner was given that endpoint too. The completed size above
83
+ // comes from the stored object either way.
84
+ const local = result.url.startsWith("/");
85
+ if (local || process.env.S3_ENDPOINT) {
86
+ const download = await ctx.get(local ? `${apiBase}${result.url}` : result.url);
87
+ expect(download.status()).toBe(200);
88
+ const stored = await download.body();
89
+ expect(stored.byteLength).toBe(file.byteLength);
90
+ expect(sha256(stored)).toBe(sha256(file));
91
+ }
92
+ } finally {
93
+ await ctx.dispose();
94
+ }
95
+ });
96
+
97
+ test("chunked upload: other users see 404 for every upload route", async ({ playwright }) => {
98
+ const owner = await session(playwright);
99
+ const other = await session(playwright);
100
+ try {
101
+ test.skip(!owner.userId || !other.userId, "requires the auth module");
102
+ const upload = await create(owner.ctx, 10);
103
+ for (const res of [
104
+ await other.ctx.get(`${uploads}/${upload.id}`),
105
+ await putPart(other.ctx, upload.id, 1, Buffer.alloc(10)),
106
+ await other.ctx.post(`${uploads}/${upload.id}/complete`, { data: {} }),
107
+ await other.ctx.delete(`${uploads}/${upload.id}`),
108
+ ]) {
109
+ expect(res.status()).toBe(404);
110
+ expect(await res.json()).toMatchObject({ error: { code: "UPLOAD_NOT_FOUND" } });
111
+ }
112
+ expect((await owner.ctx.get(`${uploads}/${upload.id}`)).status()).toBe(200);
113
+ } finally {
114
+ await owner.ctx.dispose();
115
+ await other.ctx.dispose();
116
+ }
117
+ });
118
+
119
+ test("chunked upload: DELETE cancels the upload and later parts are refused", async ({ playwright }) => {
120
+ const { ctx } = await session(playwright);
121
+ try {
122
+ const upload = await create(ctx, 20);
123
+ expect((await ctx.delete(`${uploads}/${upload.id}`)).status()).toBe(204);
124
+ expect(await (await ctx.get(`${uploads}/${upload.id}`)).json()).toMatchObject({ status: "aborted" });
125
+ const late = await putPart(ctx, upload.id, 1, Buffer.alloc(20));
126
+ expect(late.status()).toBe(410);
127
+ expect(await late.json()).toMatchObject({ error: { code: "UPLOAD_ABORTED" } });
128
+ expect((await ctx.delete(`${uploads}/${upload.id}`)).status()).toBe(204);
129
+ } finally {
130
+ await ctx.dispose();
131
+ }
132
+ });
133
+
134
+ test("chunked upload: a part of the wrong size or content type is refused", async ({ playwright }) => {
135
+ const { ctx } = await session(playwright);
136
+ try {
137
+ const upload = await create(ctx, 20);
138
+ const short = await putPart(ctx, upload.id, 1, Buffer.alloc(19));
139
+ expect(short.status()).toBe(400);
140
+ expect(await short.json()).toMatchObject({ error: { code: "UPLOAD_PART_SIZE_MISMATCH" } });
141
+
142
+ const text = await ctx.put(`${uploads}/${upload.id}/parts/1`, { data: "x".repeat(20), headers: { "content-type": "text/plain" } });
143
+ expect(text.status()).toBe(415);
144
+ expect(await text.json()).toMatchObject({ error: { code: "UNSUPPORTED_MEDIA_TYPE" } });
145
+
146
+ const incomplete = await ctx.post(`${uploads}/${upload.id}/complete`, { data: {} });
147
+ expect(incomplete.status()).toBe(409);
148
+ expect(await incomplete.json()).toMatchObject({ error: { code: "UPLOAD_INCOMPLETE" } });
149
+ expect((await ctx.delete(`${uploads}/${upload.id}`)).status()).toBe(204);
150
+ } finally {
151
+ await ctx.dispose();
152
+ }
153
+ });
@@ -1,23 +1,51 @@
1
1
  {
2
- "manifestVersion": 3,
2
+ "manifestVersion": 4,
3
3
  "name": "file-upload",
4
- "description": "Multipart file upload endpoint that stores each user's files via object storage and presigns them only for the uploader or an administrator.",
4
+ "description": "File uploads through object storage: a single-request multipart endpoint and resumable chunked uploads whose state lives in the database, with downloads presigned only for the uploader or an administrator.",
5
5
  "requiresCapabilities": ["object-storage"],
6
6
  "targetApp": "api",
7
+ "apiPaths": ["/api/files/uploads/"],
8
+ "requiredProjectFiles": ["apps/api/src/config/server-options.ts"],
7
9
  "requiredProjectContents": {
8
- "apps/api/src/core/services.ts": ["isAdmin(request: Request): Promise<boolean>;"]
10
+ "apps/api/src/core/services.ts": [
11
+ "isAdmin(request: Request): Promise<boolean>;",
12
+ "tryResolve<T>(key: ServiceKey<T>): T | undefined {"
13
+ ]
9
14
  },
10
- "env": [],
15
+ "env": [
16
+ "# file-upload: resumable chunked uploads. Largest file per upload, in bytes (1 GiB).",
17
+ "FILE_UPLOAD_MAX_BYTES=1073741824",
18
+ "# Part size in bytes: at least 5 MiB and within API_MAX_REQUEST_BODY_SIZE (8 MiB).",
19
+ "FILE_UPLOAD_PART_BYTES=8388608",
20
+ "# Seconds an unfinished upload may sit idle before it expires; each part restarts it.",
21
+ "FILE_UPLOAD_SESSION_TTL=86400",
22
+ "# Seconds between sweeps that abort expired uploads on every replica; 0 turns it off.",
23
+ "FILE_UPLOAD_SWEEP_INTERVAL=300",
24
+ "# Part uploads per caller in each RATE_LIMIT_TTL window when rate-limit is installed.",
25
+ "FILE_UPLOAD_RATE_LIMIT_MAX=3000"
26
+ ],
11
27
  "inject": [
12
28
  { "file": "apps/api/src/app.ts", "marker": "// podokit:end:imports", "text": "import { filesModule } from \"./files/files.module\";" },
13
- { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "filesModule," }
29
+ { "file": "apps/api/src/app.ts", "marker": "// podokit:end:modules", "text": "filesModule," },
30
+ {
31
+ "file": "AGENTS.md",
32
+ "marker": "<!-- podokit:end:agents-modules -->",
33
+ "optional": true,
34
+ "text": "### file-upload\n- `POST /files` stores one multipart file under `uploads/<userId>/`; `GET /files/:key/url` presigns it for the owner or an administrator.\n- For large files use the resumable upload in the browser: `uploadFile(file, { onProgress, signal })` and `resumeUpload(id, file)` from `$lib/uploads`. They create a session (`POST /files/uploads`), send parts (`PUT /files/uploads/:id/parts/:n`, `application/octet-stream`, `Content-Length` equal to the part size; with `XMLHttpRequest` in the browser so `onProgress` reports bytes), retry failed parts honoring `Retry-After`, and complete (`POST /files/uploads/:id/complete`). `GET /files/uploads/:id` lists stored parts; `DELETE` cancels.\n- Upload sessions live in the database (`file_upload_sessions`, `file_upload_parts`), so any API replica serves any request; never keep upload state in memory. Run `{{apiRun}} migrate:all{{apiWorkspaceArg}}` after adding the module.\n- Parts are larger than the web `BODY_SIZE_LIMIT`, so the gateway sends `/api/files/uploads/` straight to the API: `podo add` listed it in `.podokit/dev.json` `apiPaths` and each deployment profile's `exposure.apiPaths`, and `podo deploy init` adds it to new profiles. Keep it there.\n- POST bodies must be `application/json` and parts `application/octet-stream` (else 415 `UNSUPPORTED_MEDIA_TYPE`), and a browser `Origin` outside `CORS_ORIGIN` gets 403. Branch on `error.code`: `UPLOAD_NOT_FOUND`, `UPLOAD_TOO_LARGE`, `UPLOAD_PART_SIZE_MISMATCH`, `UPLOAD_INCOMPLETE`, `UPLOAD_COMPLETING`, `UPLOAD_EXPIRED`, `UPLOAD_ABORTED`.\n"
35
+ }
14
36
  ],
15
37
  "instructions": [
16
38
  "Configure the selected object-storage provider before uploading files.",
39
+ "Run migrations to create the upload session tables: {{apiRun}} migrate:all{{apiWorkspaceArg}}",
17
40
  "Uploads are stored as uploads/<userId>/<uuid>-<name>. Add the auth module so every upload has an owner.",
18
41
  "Upload a file: curl -b cookies.txt -F 'file=@./some.png' localhost:5002/files",
19
42
  "The response includes { key, url } — an S3 presigned URL or a signed local download URL.",
20
43
  "Presign it again (owner or administrator, key URL-encoded): curl -b cookies.txt localhost:5002/files/uploads%2F<userId>%2F<file>/url",
21
- "Uploads stored before per-user keys (uploads/<file>) can only be presigned by administrators."
44
+ "Uploads stored before per-user keys (uploads/<file>) can only be presigned by administrators.",
45
+ "Start a resumable upload: curl -b cookies.txt -XPOST localhost:5002/files/uploads -H 'content-type: application/json' -d '{\"filename\":\"big.bin\",\"size\":20000000}'",
46
+ "Send each part with its exact size: curl -b cookies.txt -XPUT localhost:5002/files/uploads/<id>/parts/1 -H 'content-type: application/octet-stream' --data-binary @part-1",
47
+ "Resume from GET /files/uploads/<id> (receivedParts), finish with POST /files/uploads/<id>/complete and '{}' as JSON, or cancel with DELETE /files/uploads/<id>.",
48
+ "In the browser, call uploadFile(file, { onProgress, signal }) or resumeUpload(id, file) from $lib/uploads.",
49
+ "Parts exceed the web server's BODY_SIZE_LIMIT, so they reach the API directly through /api/files/uploads/ (listed above as a direct API path). podo deploy init adds it to new deployment profiles."
22
50
  ]
23
51
  }
@@ -36,7 +36,7 @@ export const jobsLocalModule: PodokitModule = {
36
36
  });
37
37
  void queue.process("demo", () => undefined);
38
38
  services.register(JOBS, queue, () => queue.close());
39
- services.onStart(() => queue.start());
39
+ services.supervise("jobs", { connect: () => queue.start() });
40
40
  },
41
41
  plugin: localJobsPlugin,
42
42
  };
@@ -25,6 +25,29 @@ describe("local job queue", () => {
25
25
  queue.close();
26
26
  });
27
27
 
28
+ it("creates its table on a later start after the database was unreachable", async () => {
29
+ const database = new SQL(":memory:");
30
+ sql = database;
31
+ let unreachable = true;
32
+ const flaky = new Proxy(database, {
33
+ apply: (target, thisArg, args) =>
34
+ unreachable
35
+ ? Promise.reject(new Error("Connection refused"))
36
+ : Reflect.apply(target, thisArg, args),
37
+ });
38
+ const queue = new LocalJobQueue(flaky, { pollIntervalMs: 10 });
39
+ const payloads: unknown[] = [];
40
+ await queue.process("demo", (payload) => { payloads.push(payload); });
41
+
42
+ await expect(queue.start()).rejects.toThrow("Connection refused");
43
+ unreachable = false;
44
+ await queue.start();
45
+ await queue.enqueue("demo", { text: "after outage" });
46
+ for (let waited = 0; payloads.length === 0 && waited < 1_000; waited += 10) await Bun.sleep(10);
47
+ expect(payloads).toEqual([{ text: "after outage" }]);
48
+ queue.close();
49
+ });
50
+
28
51
  it("retries failures and records the final error", async () => {
29
52
  sql = new SQL(":memory:");
30
53
  let now = 1_000;
@@ -47,29 +47,18 @@ export class LocalJobQueue implements JobQueue {
47
47
  }
48
48
 
49
49
  initialize(): Promise<void> {
50
- this.initialized ??= (async () => {
51
- await this.sql`
52
- CREATE TABLE IF NOT EXISTS "podokit_job" (
53
- "id" text PRIMARY KEY,
54
- "name" text NOT NULL,
55
- "payload" text NOT NULL,
56
- "status" text NOT NULL,
57
- "attempts" integer NOT NULL,
58
- "maxAttempts" integer NOT NULL,
59
- "availableAt" bigint NOT NULL,
60
- "createdAt" bigint NOT NULL,
61
- "updatedAt" bigint NOT NULL,
62
- "error" text
63
- )
64
- `;
65
- await this.sql`
66
- CREATE INDEX IF NOT EXISTS "podokit_job_pending"
67
- ON "podokit_job" ("status", "availableAt", "createdAt")
68
- `;
69
- })();
50
+ if (!this.initialized) {
51
+ const attempt = this.createTables();
52
+ this.initialized = attempt;
53
+ // Forget a failed attempt so the next call retries instead of replaying the failure.
54
+ void attempt.catch(() => {
55
+ if (this.initialized === attempt) this.initialized = undefined;
56
+ });
57
+ }
70
58
  return this.initialized;
71
59
  }
72
60
 
61
+ /** Creates the table, then polls for jobs. Rejects while the database is unreachable. */
73
62
  async start(): Promise<void> {
74
63
  if (this.started) return;
75
64
  await this.initialize();
@@ -171,6 +160,27 @@ export class LocalJobQueue implements JobQueue {
171
160
  this.handlers.clear();
172
161
  }
173
162
 
163
+ private async createTables(): Promise<void> {
164
+ await this.sql`
165
+ CREATE TABLE IF NOT EXISTS "podokit_job" (
166
+ "id" text PRIMARY KEY,
167
+ "name" text NOT NULL,
168
+ "payload" text NOT NULL,
169
+ "status" text NOT NULL,
170
+ "attempts" integer NOT NULL,
171
+ "maxAttempts" integer NOT NULL,
172
+ "availableAt" bigint NOT NULL,
173
+ "createdAt" bigint NOT NULL,
174
+ "updatedAt" bigint NOT NULL,
175
+ "error" text
176
+ )
177
+ `;
178
+ await this.sql`
179
+ CREATE INDEX IF NOT EXISTS "podokit_job_pending"
180
+ ON "podokit_job" ("status", "availableAt", "createdAt")
181
+ `;
182
+ }
183
+
174
184
  private schedule(delay: number): void {
175
185
  if (!this.started) return;
176
186
  this.timer = setTimeout(() => {