@podosoft/podokit 1.2.0 → 1.4.0
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/add.d.ts +13 -2
- package/dist/add.js +135 -20
- 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 +23 -2
- package/dist/lib.d.ts +4 -2
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +26 -6
- 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 +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -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 +10 -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/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- 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/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/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 +14 -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/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- 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/admin/users.test.ts +59 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
- 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/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -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/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
- 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 +15 -12
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
- 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/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- 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 +147 -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.module.spec.ts +48 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/modules/sse/module.manifest.json +3 -1
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +22 -0
- package/dist/update.js +68 -8
- package/package.json +2 -2
- 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
|
@@ -1,7 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ConnectionOptions } from "bullmq";
|
|
2
|
+
import { redisCommandTimeoutMs, redisConnectionOptions } from "../config/redis.connection";
|
|
2
3
|
|
|
3
4
|
export const DEMO_QUEUE = "demo";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/** Wait before reconnect attempt `attempt`: 250 ms, doubling up to 5 seconds. */
|
|
7
|
+
export function reconnectDelay(attempt: number): number {
|
|
8
|
+
return Math.min(250 * 2 ** Math.max(0, attempt - 1), 5_000);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Workers block on Redis, so BullMQ requires their commands to wait for a reply indefinitely. */
|
|
12
|
+
export function redisConnection(env: NodeJS.ProcessEnv = process.env): ConnectionOptions {
|
|
13
|
+
return {
|
|
14
|
+
...redisConnectionOptions(env, { maxRetriesPerRequest: null }),
|
|
15
|
+
retryStrategy: reconnectDelay,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The API's connection for adding and reading jobs. Commands fail at once while
|
|
21
|
+
* it is disconnected and wait at most `REDIS_COMMAND_TIMEOUT_MS` for a reply, so
|
|
22
|
+
* a request that enqueues a job cannot hang on Redis. It does not reconnect by
|
|
23
|
+
* itself: the service registry's supervisor reopens it.
|
|
24
|
+
*/
|
|
25
|
+
export function producerConnection(env: NodeJS.ProcessEnv = process.env): ConnectionOptions {
|
|
26
|
+
return {
|
|
27
|
+
...redisConnectionOptions(env, {
|
|
28
|
+
lazyConnect: true,
|
|
29
|
+
enableOfflineQueue: false,
|
|
30
|
+
connectTimeout: 5_000,
|
|
31
|
+
commandTimeout: redisCommandTimeoutMs(env),
|
|
32
|
+
}),
|
|
33
|
+
retryStrategy: () => null,
|
|
34
|
+
};
|
|
7
35
|
}
|
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
"apps/api/src/config/providers.ts",
|
|
9
9
|
"apps/api/src/config/redis.connection.ts"
|
|
10
10
|
],
|
|
11
|
+
"requiredProjectContents": {
|
|
12
|
+
"apps/api/src/core/services.ts": ["supervise(name: string, connection: SupervisedConnection): void {"],
|
|
13
|
+
"apps/api/src/config/redis.connection.ts": ["export function redisCommandTimeoutMs("]
|
|
14
|
+
},
|
|
11
15
|
"managedGlobs": [
|
|
12
16
|
"apps/api/src/jobs/**",
|
|
13
17
|
"apps/api/src/main-worker.ts"
|
|
14
18
|
],
|
|
15
19
|
"targetApp": "api",
|
|
16
20
|
"dependencies": {
|
|
17
|
-
"bullmq": "^5.
|
|
21
|
+
"bullmq": "^5.57.0"
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
20
24
|
"start:worker": "bun dist/main-worker.js",
|
|
@@ -8,9 +8,7 @@ export const eventsRedisModule: PodokitModule = {
|
|
|
8
8
|
configure: (_env, services): void => {
|
|
9
9
|
if (PROVIDERS.events !== "redis") return;
|
|
10
10
|
const events = new RedisEventBus();
|
|
11
|
-
services.register(EVENTS, events,
|
|
12
|
-
|
|
13
|
-
});
|
|
14
|
-
services.onStart(() => events.ready());
|
|
11
|
+
services.register(EVENTS, events, () => events.close());
|
|
12
|
+
services.supervise("events", events);
|
|
15
13
|
},
|
|
16
14
|
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { RedisClient } from "bun";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { RedisCommandTimeoutError } from "../config/redis.connection";
|
|
4
|
+
import { RedisEventBus } from "./redis-event.bus";
|
|
5
|
+
|
|
6
|
+
type Listener = (message: string, channel: string) => void;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One Redis server shared by fake clients: it can go down, and it drops every
|
|
10
|
+
* connection when it does. While paused it keeps connections open and answers nothing.
|
|
11
|
+
*/
|
|
12
|
+
class FakeRedis {
|
|
13
|
+
up = true;
|
|
14
|
+
paused = false;
|
|
15
|
+
readonly clients: FakeClient[] = [];
|
|
16
|
+
|
|
17
|
+
client(): RedisClient {
|
|
18
|
+
const client = new FakeClient(this);
|
|
19
|
+
this.clients.push(client);
|
|
20
|
+
return client as unknown as RedisClient;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
stop(): void {
|
|
24
|
+
this.up = false;
|
|
25
|
+
for (const client of this.clients) client.drop();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
deliver(channel: string, message: string): number {
|
|
29
|
+
let receivers = 0;
|
|
30
|
+
for (const client of this.clients) {
|
|
31
|
+
if (!client.connected || !client.channels.has(channel)) continue;
|
|
32
|
+
receivers += 1;
|
|
33
|
+
client.listener?.(message, channel);
|
|
34
|
+
}
|
|
35
|
+
return receivers;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class FakeClient {
|
|
40
|
+
connected = false;
|
|
41
|
+
onclose: ((error: Error) => void) | null = null;
|
|
42
|
+
readonly channels = new Set<string>();
|
|
43
|
+
listener?: Listener;
|
|
44
|
+
private readonly pending = new Set<(error: Error) => void>();
|
|
45
|
+
|
|
46
|
+
constructor(private readonly server: FakeRedis) {}
|
|
47
|
+
|
|
48
|
+
/** Never answers while the server is paused; closing the connection fails the command. */
|
|
49
|
+
private answer<T>(value: () => T): Promise<T> {
|
|
50
|
+
if (!this.server.paused) return Promise.resolve(value());
|
|
51
|
+
return new Promise<T>((_resolve, reject) => {
|
|
52
|
+
this.pending.add(reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
connect(): Promise<void> {
|
|
57
|
+
if (!this.server.up) {
|
|
58
|
+
this.onclose?.(new Error("Connection closed"));
|
|
59
|
+
return Promise.reject(new Error("Connection closed"));
|
|
60
|
+
}
|
|
61
|
+
this.connected = true;
|
|
62
|
+
return Promise.resolve();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ping(): Promise<string> {
|
|
66
|
+
return this.connected ? this.answer(() => "PONG") : Promise.reject(new Error("Connection closed"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
publish(channel: string, message: string): Promise<number> {
|
|
70
|
+
if (!this.connected) {
|
|
71
|
+
return Promise.reject(new Error("Connection is closed and offline queue is disabled"));
|
|
72
|
+
}
|
|
73
|
+
return this.answer(() => this.server.deliver(channel, message));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
subscribe(channel: string, listener: Listener): Promise<number> {
|
|
77
|
+
if (!this.connected) return Promise.reject(new Error("Connection closed"));
|
|
78
|
+
return this.answer(() => {
|
|
79
|
+
this.channels.add(channel);
|
|
80
|
+
this.listener = listener;
|
|
81
|
+
return this.channels.size;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
drop(): void {
|
|
86
|
+
if (!this.connected) return;
|
|
87
|
+
// Bun forgets the server subscription but keeps its listener.
|
|
88
|
+
this.connected = false;
|
|
89
|
+
this.channels.clear();
|
|
90
|
+
this.onclose?.(new Error("Connection closed"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close(): void {
|
|
94
|
+
if (!this.connected) return;
|
|
95
|
+
this.connected = false;
|
|
96
|
+
for (const reject of this.pending) reject(new Error("Connection closed"));
|
|
97
|
+
this.pending.clear();
|
|
98
|
+
this.onclose?.(new Error("Connection closed"));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bus(server: FakeRedis): RedisEventBus {
|
|
103
|
+
return new RedisEventBus(
|
|
104
|
+
{ EVENTS_REDIS_CHANNEL: "test:events", REDIS_COMMAND_TIMEOUT_MS: "50" },
|
|
105
|
+
() => server.client(),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("RedisEventBus reconnects", () => {
|
|
110
|
+
it("keeps handlers registered without Redis and delivers once the channel is subscribed", async () => {
|
|
111
|
+
const server = new FakeRedis();
|
|
112
|
+
server.up = false;
|
|
113
|
+
const events = bus(server);
|
|
114
|
+
let interruptions = 0;
|
|
115
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
116
|
+
const received: unknown[] = [];
|
|
117
|
+
await events.subscribe("sse", (event) => { received.push(event); });
|
|
118
|
+
|
|
119
|
+
await expect(events.connect()).rejects.toThrow("Connection closed");
|
|
120
|
+
await expect(events.ready()).rejects.toThrow("not subscribed");
|
|
121
|
+
server.up = true;
|
|
122
|
+
await events.connect();
|
|
123
|
+
await events.ready();
|
|
124
|
+
|
|
125
|
+
await events.publish("sse", { id: 1 });
|
|
126
|
+
expect(received).toEqual([{ id: 1 }]);
|
|
127
|
+
// Streams opened before the first subscription may have missed events.
|
|
128
|
+
expect(interruptions).toBe(1);
|
|
129
|
+
events.close();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("subscribes again after Redis drops the connections and reports one interruption", async () => {
|
|
133
|
+
const server = new FakeRedis();
|
|
134
|
+
const events = bus(server);
|
|
135
|
+
const lost: unknown[] = [];
|
|
136
|
+
events.onLost((reason) => { lost.push(reason); });
|
|
137
|
+
let interruptions = 0;
|
|
138
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
139
|
+
const received: unknown[] = [];
|
|
140
|
+
await events.subscribe("config.changed", (event) => { received.push(event); });
|
|
141
|
+
await events.connect();
|
|
142
|
+
expect(interruptions).toBe(0);
|
|
143
|
+
|
|
144
|
+
server.stop();
|
|
145
|
+
expect(lost.length).toBeGreaterThan(0);
|
|
146
|
+
await expect(events.ready()).rejects.toThrow("not subscribed");
|
|
147
|
+
await expect(events.publish("config.changed", { origin: "a" })).rejects.toThrow("offline queue");
|
|
148
|
+
server.up = true;
|
|
149
|
+
await events.connect();
|
|
150
|
+
|
|
151
|
+
expect(interruptions).toBe(1);
|
|
152
|
+
await events.publish("config.changed", { origin: "b" });
|
|
153
|
+
expect(received).toEqual([{ origin: "b" }]);
|
|
154
|
+
events.close();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("reports no interruption when only the publisher reconnects", async () => {
|
|
158
|
+
const server = new FakeRedis();
|
|
159
|
+
const events = bus(server);
|
|
160
|
+
let interruptions = 0;
|
|
161
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
162
|
+
await events.connect();
|
|
163
|
+
|
|
164
|
+
server.clients[0]?.drop();
|
|
165
|
+
await events.connect();
|
|
166
|
+
expect(interruptions).toBe(0);
|
|
167
|
+
await events.ready();
|
|
168
|
+
events.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not report connections it closes itself", async () => {
|
|
172
|
+
const server = new FakeRedis();
|
|
173
|
+
const events = bus(server);
|
|
174
|
+
const lost: unknown[] = [];
|
|
175
|
+
events.onLost((reason) => { lost.push(reason); });
|
|
176
|
+
await events.connect();
|
|
177
|
+
|
|
178
|
+
events.close();
|
|
179
|
+
expect(lost).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("fails a publish Redis does not answer and hands the publisher to the supervisor", async () => {
|
|
183
|
+
const server = new FakeRedis();
|
|
184
|
+
const events = bus(server);
|
|
185
|
+
const lost: unknown[] = [];
|
|
186
|
+
events.onLost((reason) => { lost.push(reason); });
|
|
187
|
+
let interruptions = 0;
|
|
188
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
189
|
+
const received: unknown[] = [];
|
|
190
|
+
await events.subscribe("sse", (event) => { received.push(event); });
|
|
191
|
+
await events.connect();
|
|
192
|
+
|
|
193
|
+
server.paused = true;
|
|
194
|
+
const started = Date.now();
|
|
195
|
+
await expect(events.publish("sse", { id: 1 })).rejects.toBeInstanceOf(RedisCommandTimeoutError);
|
|
196
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
197
|
+
expect(lost.length).toBe(1);
|
|
198
|
+
await expect(events.ready()).rejects.toThrow("Connection closed");
|
|
199
|
+
|
|
200
|
+
server.paused = false;
|
|
201
|
+
await events.connect();
|
|
202
|
+
await events.publish("sse", { id: 2 });
|
|
203
|
+
expect(received).toEqual([{ id: 2 }]);
|
|
204
|
+
expect(interruptions).toBe(0);
|
|
205
|
+
events.close();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("fails a subscription Redis does not confirm and subscribes on the next attempt", async () => {
|
|
209
|
+
const server = new FakeRedis();
|
|
210
|
+
server.paused = true;
|
|
211
|
+
const events = bus(server);
|
|
212
|
+
let interruptions = 0;
|
|
213
|
+
events.onInterruption(() => { interruptions += 1; });
|
|
214
|
+
|
|
215
|
+
await expect(events.connect()).rejects.toBeInstanceOf(RedisCommandTimeoutError);
|
|
216
|
+
await expect(events.ready()).rejects.toThrow("not subscribed");
|
|
217
|
+
|
|
218
|
+
server.paused = false;
|
|
219
|
+
await events.connect();
|
|
220
|
+
await events.ready();
|
|
221
|
+
expect(interruptions).toBe(1);
|
|
222
|
+
events.close();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -1,48 +1,83 @@
|
|
|
1
1
|
import { RedisClient } from "bun";
|
|
2
2
|
import type { EventBus, EventHandler, Unsubscribe } from "@podosoft/podokit-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
redisCommandTimeoutMs,
|
|
5
|
+
redisConnectionUrl,
|
|
6
|
+
withReplyTimeout,
|
|
7
|
+
} from "../config/redis.connection";
|
|
8
|
+
import type { SupervisedConnection } from "../core/services";
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Publishing fails at once while disconnected instead of queueing, and the
|
|
12
|
+
* supervisor reconnects instead of Bun, which gives up after a fixed number of
|
|
13
|
+
* attempts and never restores subscriptions.
|
|
14
|
+
*/
|
|
15
|
+
function redisClientFactory(env: NodeJS.ProcessEnv): () => RedisClient {
|
|
16
|
+
const url = redisConnectionUrl(env);
|
|
17
|
+
return () => new RedisClient(url, {
|
|
18
|
+
connectionTimeout: 5_000,
|
|
19
|
+
autoReconnect: false,
|
|
20
|
+
enableOfflineQueue: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every replica subscribes to one Redis channel and dispatches to its local
|
|
26
|
+
* handlers by topic. Handlers are local, so they survive reconnects; the
|
|
27
|
+
* channel subscription is opened again on each reconnect.
|
|
28
|
+
*/
|
|
29
|
+
export class RedisEventBus implements EventBus, SupervisedConnection {
|
|
6
30
|
private readonly publisher: RedisClient;
|
|
7
|
-
private readonly subscriber: RedisClient;
|
|
8
31
|
private readonly channel: string;
|
|
9
32
|
private readonly maxEventBytes: number;
|
|
33
|
+
private readonly commandTimeoutMs: number;
|
|
34
|
+
private readonly createClient: () => RedisClient;
|
|
10
35
|
private readonly handlers = new Map<string, Set<EventHandler>>();
|
|
11
|
-
private
|
|
36
|
+
private readonly interruptionListeners = new Set<() => void>();
|
|
37
|
+
// Set while a connection holds the channel subscription.
|
|
38
|
+
private subscriber?: RedisClient;
|
|
39
|
+
// Whether events may have been missed since the channel was last subscribed.
|
|
40
|
+
private missedEvents = false;
|
|
41
|
+
private lostListener?: (reason: unknown) => void;
|
|
42
|
+
private closed = false;
|
|
12
43
|
|
|
13
|
-
constructor(
|
|
14
|
-
|
|
15
|
-
|
|
44
|
+
constructor(
|
|
45
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
46
|
+
createClient: () => RedisClient = redisClientFactory(env),
|
|
47
|
+
) {
|
|
48
|
+
this.createClient = createClient;
|
|
49
|
+
this.publisher = createClient();
|
|
50
|
+
this.publisher.onclose = (error) => this.report(error);
|
|
16
51
|
this.channel = env.EVENTS_REDIS_CHANNEL?.trim() || "podokit:events";
|
|
17
52
|
this.maxEventBytes = Number(env.SSE_MAX_EVENT_BYTES ?? 65_536);
|
|
18
53
|
if (!Number.isSafeInteger(this.maxEventBytes) || this.maxEventBytes < 1) {
|
|
19
54
|
throw new Error("SSE_MAX_EVENT_BYTES must be a positive integer");
|
|
20
55
|
}
|
|
56
|
+
this.commandTimeoutMs = redisCommandTimeoutMs(env);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onLost(listener: (reason: unknown) => void): void {
|
|
60
|
+
this.lostListener = listener;
|
|
21
61
|
}
|
|
22
62
|
|
|
63
|
+
/** Opens whichever connection is closed and subscribes the channel again. */
|
|
23
64
|
async connect(): Promise<void> {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
void Promise.resolve(handler(record.event)).catch(() => undefined);
|
|
35
|
-
}
|
|
36
|
-
} catch {
|
|
37
|
-
process.stderr.write("Discard malformed event transport message\n");
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
await this.subscriber.subscribe(this.channel, this.listener);
|
|
65
|
+
try {
|
|
66
|
+
if (!this.publisher.connected) await this.publisher.connect();
|
|
67
|
+
this.subscriber ??= await this.openSubscriber();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!this.subscriber) this.missedEvents = true;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
if (!this.missedEvents) return;
|
|
73
|
+
this.missedEvents = false;
|
|
74
|
+
for (const listener of [...this.interruptionListeners]) listener();
|
|
41
75
|
}
|
|
42
76
|
|
|
77
|
+
/** Rejects until the channel is subscribed and while the publisher cannot reach Redis. */
|
|
43
78
|
async ready(): Promise<void> {
|
|
44
|
-
|
|
45
|
-
await this.publisher.ping();
|
|
79
|
+
if (!this.subscriber) throw new Error("Event transport is not subscribed");
|
|
80
|
+
await this.send(() => this.publisher.ping());
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
async publish(topic: string, event: unknown): Promise<void> {
|
|
@@ -51,25 +86,79 @@ export class RedisEventBus implements EventBus {
|
|
|
51
86
|
if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) {
|
|
52
87
|
throw new Error(`Event exceeds maxEventBytes (${this.maxEventBytes})`);
|
|
53
88
|
}
|
|
54
|
-
await this.publisher.publish(this.channel, message);
|
|
89
|
+
await this.send(() => this.publisher.publish(this.channel, message));
|
|
55
90
|
}
|
|
56
91
|
|
|
57
|
-
|
|
58
|
-
await this.connect();
|
|
92
|
+
subscribe(topic: string, handler: EventHandler): Promise<Unsubscribe> {
|
|
59
93
|
const handlers = this.handlers.get(topic) ?? new Set<EventHandler>();
|
|
60
94
|
handlers.add(handler);
|
|
61
95
|
this.handlers.set(topic, handlers);
|
|
62
|
-
return () => {
|
|
96
|
+
return Promise.resolve(() => {
|
|
63
97
|
handlers.delete(handler);
|
|
64
|
-
if (handlers.size === 0) this.handlers.delete(topic);
|
|
98
|
+
if (handlers.size === 0 && this.handlers.get(topic) === handlers) this.handlers.delete(topic);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onInterruption(listener: () => void): () => void {
|
|
103
|
+
this.interruptionListeners.add(listener);
|
|
104
|
+
return () => {
|
|
105
|
+
this.interruptionListeners.delete(listener);
|
|
65
106
|
};
|
|
66
107
|
}
|
|
67
108
|
|
|
68
|
-
|
|
69
|
-
|
|
109
|
+
close(): void {
|
|
110
|
+
this.closed = true;
|
|
70
111
|
this.handlers.clear();
|
|
71
|
-
this.
|
|
112
|
+
this.interruptionListeners.clear();
|
|
113
|
+
this.subscriber?.close();
|
|
114
|
+
this.subscriber = undefined;
|
|
72
115
|
this.publisher.close();
|
|
73
|
-
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Fails a publisher command Redis does not answer in time and closes the connection for the supervisor to reopen. */
|
|
119
|
+
private async send<T>(command: () => Promise<T>): Promise<T> {
|
|
120
|
+
return withReplyTimeout(command(), this.commandTimeoutMs, () => this.publisher.close());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private readonly receive = (message: string): void => {
|
|
124
|
+
if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) return;
|
|
125
|
+
try {
|
|
126
|
+
const envelope = JSON.parse(message) as unknown;
|
|
127
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return;
|
|
128
|
+
const record = envelope as Record<string, unknown>;
|
|
129
|
+
if (typeof record.topic !== "string") return;
|
|
130
|
+
for (const handler of this.handlers.get(record.topic) ?? []) {
|
|
131
|
+
void Promise.resolve(handler(record.event)).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
process.stderr.write("Discard malformed event transport message\n");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** A fresh connection, because Bun keeps the listeners of a dropped one without the server subscription. */
|
|
139
|
+
private async openSubscriber(): Promise<RedisClient> {
|
|
140
|
+
const subscriber = this.createClient();
|
|
141
|
+
subscriber.onclose = (error) => {
|
|
142
|
+
if (this.subscriber !== subscriber) return;
|
|
143
|
+
this.subscriber = undefined;
|
|
144
|
+
this.missedEvents = true;
|
|
145
|
+
this.report(error);
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
await subscriber.connect();
|
|
149
|
+
await withReplyTimeout(
|
|
150
|
+
subscriber.subscribe(this.channel, this.receive),
|
|
151
|
+
this.commandTimeoutMs,
|
|
152
|
+
() => subscriber.close(),
|
|
153
|
+
);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
subscriber.close();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
return subscriber;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private report(reason: unknown): void {
|
|
162
|
+
if (!this.closed) this.lostListener?.(reason);
|
|
74
163
|
}
|
|
75
164
|
}
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
"apps/api/src/config/providers.ts",
|
|
8
8
|
"apps/api/src/config/redis.connection.ts"
|
|
9
9
|
],
|
|
10
|
+
"requiredProjectContents": {
|
|
11
|
+
"apps/api/src/core/services.ts": ["supervise(name: string, connection: SupervisedConnection): void {"],
|
|
12
|
+
"apps/api/src/config/redis.connection.ts": ["export async function withReplyTimeout<T>("]
|
|
13
|
+
},
|
|
10
14
|
"managedGlobs": [
|
|
11
15
|
"apps/api/src/events-provider-redis/**"
|
|
12
16
|
],
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { Elysia, t } from "elysia";
|
|
3
|
+
import type { ElysiaPlugin, RequestIdentity } from "../core/services";
|
|
4
|
+
import type { ChunkedUploads } from "./chunked-uploads";
|
|
5
|
+
|
|
6
|
+
const JSON_TYPE = "application/json";
|
|
7
|
+
const BINARY_TYPE = "application/octet-stream";
|
|
8
|
+
const CONTENT_TYPE = "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+(\\s*;.*)?$";
|
|
9
|
+
|
|
10
|
+
const uploadId = t.Object({ id: t.String({ minLength: 1, maxLength: 64 }) });
|
|
11
|
+
|
|
12
|
+
/** The origins in CORS_ORIGIN, which a browser's Origin header must match. */
|
|
13
|
+
export function allowedOrigins(corsOrigin: string | undefined): string[] {
|
|
14
|
+
return (corsOrigin ?? "")
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((origin) => origin.trim())
|
|
17
|
+
.filter((origin) => origin.length > 0)
|
|
18
|
+
.map((origin) => {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(origin).origin;
|
|
21
|
+
} catch {
|
|
22
|
+
return origin;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mediaType(request: Request): string {
|
|
28
|
+
return (request.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upload routes may be served on a direct API path, which skips the web
|
|
33
|
+
* server's cross-site form check. Requiring a JSON or binary content type makes
|
|
34
|
+
* every cross-origin browser request preflighted, which CORS refuses for other
|
|
35
|
+
* origins, and a browser Origin outside CORS_ORIGIN is rejected outright.
|
|
36
|
+
*/
|
|
37
|
+
function guard(origins: readonly string[], type?: string): (context: { request: Request }) => void {
|
|
38
|
+
return ({ request }) => {
|
|
39
|
+
const origin = request.headers.get("origin");
|
|
40
|
+
if (origins.length > 0 && origin !== null && !origins.includes(origin)) {
|
|
41
|
+
throw new AppException("ORIGIN_NOT_ALLOWED", "Requests from this origin cannot change uploads", 403);
|
|
42
|
+
}
|
|
43
|
+
if (type !== undefined && mediaType(request) !== type) {
|
|
44
|
+
throw new AppException("UNSUPPORTED_MEDIA_TYPE", `Send this request as ${type}`, 415);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function declaredLength(request: Request): number | null {
|
|
50
|
+
const value = request.headers.get("content-length");
|
|
51
|
+
if (value === null) return null;
|
|
52
|
+
return /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resumable uploads: create a session, PUT its parts in any order (again to
|
|
57
|
+
* replace one), read the status to resume, then complete or delete it. Only the
|
|
58
|
+
* owner sees a session; others receive 404 so session ids cannot be probed.
|
|
59
|
+
*/
|
|
60
|
+
export function chunkedUploadRoutes(
|
|
61
|
+
uploads: ChunkedUploads,
|
|
62
|
+
identity: RequestIdentity,
|
|
63
|
+
origins: readonly string[],
|
|
64
|
+
): ElysiaPlugin {
|
|
65
|
+
return new Elysia({ name: "podokit.files.uploads" })
|
|
66
|
+
.post("/files/uploads", async ({ request, body, set }) => {
|
|
67
|
+
const created = await uploads.create(await identity.userId(request), body);
|
|
68
|
+
set.status = 201;
|
|
69
|
+
return created;
|
|
70
|
+
}, {
|
|
71
|
+
transform: guard(origins, JSON_TYPE),
|
|
72
|
+
body: t.Object({
|
|
73
|
+
filename: t.String({ minLength: 1, maxLength: 255 }),
|
|
74
|
+
size: t.Integer({ minimum: 1 }),
|
|
75
|
+
contentType: t.Optional(t.String({ maxLength: 255, pattern: CONTENT_TYPE })),
|
|
76
|
+
}),
|
|
77
|
+
detail: {
|
|
78
|
+
tags: ["files"],
|
|
79
|
+
summary: "Start a resumable upload",
|
|
80
|
+
description:
|
|
81
|
+
"Returns the session id, the part size, and the part count. Send every part with PUT /files/uploads/{id}/parts/{partNumber}, then complete the upload.",
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
.get("/files/uploads/:id", async ({ request, params }) =>
|
|
85
|
+
uploads.status(await identity.userId(request), params.id), {
|
|
86
|
+
params: uploadId,
|
|
87
|
+
detail: {
|
|
88
|
+
tags: ["files"],
|
|
89
|
+
summary: "Read a resumable upload",
|
|
90
|
+
description: "Lists the stored part numbers, so a client can resume by sending the others.",
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
.put("/files/uploads/:id/parts/:partNumber", async ({ request, params }) =>
|
|
94
|
+
uploads.putPart(
|
|
95
|
+
await identity.userId(request),
|
|
96
|
+
params.id,
|
|
97
|
+
params.partNumber,
|
|
98
|
+
declaredLength(request),
|
|
99
|
+
request.body,
|
|
100
|
+
), {
|
|
101
|
+
// The body is streamed to storage as it arrives, never parsed or buffered here.
|
|
102
|
+
parse: "none",
|
|
103
|
+
transform: guard(origins, BINARY_TYPE),
|
|
104
|
+
params: t.Object({ id: t.String({ minLength: 1, maxLength: 64 }), partNumber: t.Numeric() }),
|
|
105
|
+
detail: {
|
|
106
|
+
tags: ["files"],
|
|
107
|
+
summary: "Store one part of a resumable upload",
|
|
108
|
+
description:
|
|
109
|
+
"Send the raw bytes as application/octet-stream with a Content-Length equal to the part size (the last part holds the remainder). Sending a part again replaces it.",
|
|
110
|
+
requestBody: {
|
|
111
|
+
required: true,
|
|
112
|
+
content: { [BINARY_TYPE]: { schema: { type: "string", format: "binary" } } },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
.post("/files/uploads/:id/complete", async ({ request, params }) =>
|
|
117
|
+
uploads.complete(await identity.userId(request), params.id), {
|
|
118
|
+
transform: guard(origins, JSON_TYPE),
|
|
119
|
+
params: uploadId,
|
|
120
|
+
body: t.Optional(t.Object({})),
|
|
121
|
+
detail: {
|
|
122
|
+
tags: ["files"],
|
|
123
|
+
summary: "Complete a resumable upload",
|
|
124
|
+
description:
|
|
125
|
+
"Joins the parts into the stored file once every part is present. Repeating the call returns the same result.",
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
.delete("/files/uploads/:id", async ({ request, params, set }) => {
|
|
129
|
+
await uploads.abort(await identity.userId(request), params.id);
|
|
130
|
+
set.status = 204;
|
|
131
|
+
}, {
|
|
132
|
+
transform: guard(origins),
|
|
133
|
+
params: uploadId,
|
|
134
|
+
detail: { tags: ["files"], summary: "Cancel a resumable upload and discard its parts" },
|
|
135
|
+
});
|
|
136
|
+
}
|