@btst/stack 2.8.1 → 2.9.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 (196) hide show
  1. package/README.md +3 -2
  2. package/dist/components/markdown/index.d.cts +15 -2
  3. package/dist/components/markdown/index.d.mts +15 -2
  4. package/dist/components/markdown/index.d.ts +15 -2
  5. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
  6. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
  7. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
  8. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
  9. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
  10. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
  11. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
  12. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
  13. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
  14. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
  15. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
  16. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
  17. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
  18. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
  19. package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
  20. package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
  21. package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
  22. package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
  23. package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
  24. package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
  25. package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
  26. package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
  27. package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
  28. package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
  29. package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
  30. package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
  31. package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
  32. package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
  33. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
  34. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
  35. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
  36. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
  37. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
  38. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
  39. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
  40. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
  41. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
  42. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
  43. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
  44. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
  45. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
  46. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
  47. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
  48. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
  49. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
  50. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
  51. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
  52. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
  53. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
  54. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
  55. package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
  56. package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
  57. package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
  58. package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
  59. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
  60. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
  61. package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
  62. package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
  63. package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
  64. package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
  65. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
  66. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
  67. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
  68. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
  69. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
  70. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
  71. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
  72. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
  73. package/dist/plugins/blog/client/index.d.cts +58 -1
  74. package/dist/plugins/blog/client/index.d.mts +58 -1
  75. package/dist/plugins/blog/client/index.d.ts +58 -1
  76. package/dist/plugins/cms/client/index.d.cts +73 -3
  77. package/dist/plugins/cms/client/index.d.mts +73 -3
  78. package/dist/plugins/cms/client/index.d.ts +73 -3
  79. package/dist/plugins/kanban/api/index.d.cts +1 -1
  80. package/dist/plugins/kanban/api/index.d.mts +1 -1
  81. package/dist/plugins/kanban/api/index.d.ts +1 -1
  82. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  83. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  84. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  85. package/dist/plugins/kanban/client/index.d.cts +1 -1
  86. package/dist/plugins/kanban/client/index.d.mts +1 -1
  87. package/dist/plugins/kanban/client/index.d.ts +1 -1
  88. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  89. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  90. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  91. package/dist/plugins/media/api/adapters/s3.cjs +106 -0
  92. package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
  93. package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
  94. package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
  95. package/dist/plugins/media/api/adapters/s3.mjs +104 -0
  96. package/dist/plugins/media/api/adapters/vercel-blob.cjs +53 -0
  97. package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
  98. package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
  99. package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
  100. package/dist/plugins/media/api/adapters/vercel-blob.mjs +51 -0
  101. package/dist/plugins/media/api/index.cjs +26 -0
  102. package/dist/plugins/media/api/index.d.cts +116 -0
  103. package/dist/plugins/media/api/index.d.mts +116 -0
  104. package/dist/plugins/media/api/index.d.ts +116 -0
  105. package/dist/plugins/media/api/index.mjs +6 -0
  106. package/dist/plugins/media/client/components/index.cjs +10 -0
  107. package/dist/plugins/media/client/components/index.d.cts +55 -0
  108. package/dist/plugins/media/client/components/index.d.mts +55 -0
  109. package/dist/plugins/media/client/components/index.d.ts +55 -0
  110. package/dist/plugins/media/client/components/index.mjs +2 -0
  111. package/dist/plugins/media/client/hooks/index.cjs +13 -0
  112. package/dist/plugins/media/client/hooks/index.d.cts +53 -0
  113. package/dist/plugins/media/client/hooks/index.d.mts +53 -0
  114. package/dist/plugins/media/client/hooks/index.d.ts +53 -0
  115. package/dist/plugins/media/client/hooks/index.mjs +1 -0
  116. package/dist/plugins/media/client/index.cjs +9 -0
  117. package/dist/plugins/media/client/index.d.cts +242 -0
  118. package/dist/plugins/media/client/index.d.mts +242 -0
  119. package/dist/plugins/media/client/index.d.ts +242 -0
  120. package/dist/plugins/media/client/index.mjs +2 -0
  121. package/dist/plugins/media/client.css +1 -0
  122. package/dist/plugins/media/query-keys.cjs +72 -0
  123. package/dist/plugins/media/query-keys.d.cts +49 -0
  124. package/dist/plugins/media/query-keys.d.mts +49 -0
  125. package/dist/plugins/media/query-keys.d.ts +49 -0
  126. package/dist/plugins/media/query-keys.mjs +70 -0
  127. package/dist/plugins/media/style.css +1 -0
  128. package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
  129. package/dist/shared/stack.BttDsJJn.d.cts +109 -0
  130. package/dist/shared/stack.BttDsJJn.d.mts +109 -0
  131. package/dist/shared/stack.BttDsJJn.d.ts +109 -0
  132. package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
  133. package/dist/shared/stack.CAni8dnD.d.cts +63 -0
  134. package/dist/shared/stack.CI8iRKKi.d.cts +286 -0
  135. package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
  136. package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
  137. package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
  138. package/dist/shared/stack.CYSwntXC.d.ts +63 -0
  139. package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
  140. package/dist/shared/stack.DJDjdG64.d.ts +286 -0
  141. package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
  142. package/dist/shared/stack.FgBVDSPi.d.mts +286 -0
  143. package/package.json +113 -4
  144. package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
  145. package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
  146. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
  147. package/src/plugins/blog/client/overrides.ts +58 -1
  148. package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
  149. package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
  150. package/src/plugins/cms/client/overrides.ts +57 -2
  151. package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
  152. package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
  153. package/src/plugins/kanban/client/overrides.ts +25 -0
  154. package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
  155. package/src/plugins/media/__tests__/getters.test.ts +274 -0
  156. package/src/plugins/media/__tests__/mutations.test.ts +299 -0
  157. package/src/plugins/media/__tests__/plugin.test.ts +752 -0
  158. package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
  159. package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
  160. package/src/plugins/media/api/adapters/local.ts +79 -0
  161. package/src/plugins/media/api/adapters/s3.ts +198 -0
  162. package/src/plugins/media/api/adapters/vercel-blob.ts +131 -0
  163. package/src/plugins/media/api/getters.ts +174 -0
  164. package/src/plugins/media/api/index.ts +41 -0
  165. package/src/plugins/media/api/mutations.ts +179 -0
  166. package/src/plugins/media/api/plugin.ts +855 -0
  167. package/src/plugins/media/api/query-key-defs.ts +41 -0
  168. package/src/plugins/media/api/serializers.ts +28 -0
  169. package/src/plugins/media/api/storage-adapter.ts +139 -0
  170. package/src/plugins/media/client/components/index.tsx +6 -0
  171. package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
  172. package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
  173. package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
  174. package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
  175. package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
  176. package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
  177. package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
  178. package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
  179. package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
  180. package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
  181. package/src/plugins/media/client/hooks/index.tsx +9 -0
  182. package/src/plugins/media/client/hooks/use-media.tsx +289 -0
  183. package/src/plugins/media/client/index.ts +4 -0
  184. package/src/plugins/media/client/overrides.ts +127 -0
  185. package/src/plugins/media/client/plugin.tsx +184 -0
  186. package/src/plugins/media/client/upload.ts +171 -0
  187. package/src/plugins/media/client/utils/image-compression.ts +131 -0
  188. package/src/plugins/media/client.css +1 -0
  189. package/src/plugins/media/db.ts +62 -0
  190. package/src/plugins/media/query-keys.ts +96 -0
  191. package/src/plugins/media/schemas.ts +37 -0
  192. package/src/plugins/media/style.css +1 -0
  193. package/src/plugins/media/types.ts +26 -0
  194. package/dist/shared/{stack.BOokfhZD.d.cts → stack.B6S3cgwN.d.cts} +16 -16
  195. package/dist/shared/{stack.CWxAl9K3.d.mts → stack.Bzfx-_lq.d.mts} +16 -16
  196. package/dist/shared/{stack.BvCR4-9H.d.ts → stack.j5SFLC1d.d.ts} +16 -16
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createMemoryAdapter } from "@btst/adapter-memory";
3
+ import { defineDb } from "@btst/db";
4
+ import type { DBAdapter as Adapter } from "@btst/db";
5
+ import { mediaSchema } from "../db";
6
+ import { listAssets } from "../api/getters";
7
+ import {
8
+ MEDIA_QUERY_KEYS,
9
+ assetListDiscriminator,
10
+ } from "../api/query-key-defs";
11
+
12
+ const createTestAdapter = (): Adapter => {
13
+ const db = defineDb({}).use(mediaSchema);
14
+ return createMemoryAdapter(db)({});
15
+ };
16
+
17
+ const makeAsset = (index: number) => ({
18
+ filename: `asset-${index}.jpg`,
19
+ originalName: `Asset ${index}.jpg`,
20
+ mimeType: "image/jpeg",
21
+ size: 1024 + index,
22
+ url: `https://example.com/${index}.jpg`,
23
+ createdAt: new Date(Date.now() + index),
24
+ });
25
+
26
+ describe("media asset list query keys", () => {
27
+ let adapter: Adapter;
28
+
29
+ beforeEach(() => {
30
+ adapter = createTestAdapter();
31
+ });
32
+
33
+ it("distinguishes unbounded and explicit first-page pagination", async () => {
34
+ for (let i = 0; i < 25; i++) {
35
+ await adapter.create({
36
+ model: "mediaAsset",
37
+ data: makeAsset(i),
38
+ });
39
+ }
40
+
41
+ const unbounded = await listAssets(adapter);
42
+ const paginated = await listAssets(adapter, { limit: 20, offset: 0 });
43
+
44
+ expect(unbounded.items).toHaveLength(25);
45
+ expect(paginated.items).toHaveLength(20);
46
+
47
+ expect(assetListDiscriminator()).not.toEqual(
48
+ assetListDiscriminator({ limit: 20, offset: 0 }),
49
+ );
50
+ expect(MEDIA_QUERY_KEYS.assetsList()).not.toEqual(
51
+ MEDIA_QUERY_KEYS.assetsList({ limit: 20, offset: 0 }),
52
+ );
53
+ });
54
+ });
@@ -0,0 +1,351 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+
6
+ // Top-level vi.mock calls are hoisted by Vitest before any imports.
7
+ // Factories are used so the packages do not need to be installed as devDependencies.
8
+
9
+ const mockSend = vi.fn().mockResolvedValue({});
10
+ const mockS3Client = vi.fn(() => ({ send: mockSend }));
11
+ const mockPutObjectCommand = vi.fn((input: unknown) => ({
12
+ input,
13
+ __type: "PutObjectCommand",
14
+ }));
15
+ const mockDeleteObjectCommand = vi.fn((input: unknown) => ({
16
+ input,
17
+ __type: "DeleteObjectCommand",
18
+ }));
19
+ const mockGetSignedUrl = vi
20
+ .fn()
21
+ .mockResolvedValue("https://s3.example.com/signed-url");
22
+
23
+ const mockHandleUpload = vi.fn().mockResolvedValue({
24
+ type: "blob.generate-client-token",
25
+ clientToken: "tok123",
26
+ });
27
+ const mockDel = vi.fn().mockResolvedValue(undefined);
28
+
29
+ vi.mock("@aws-sdk/client-s3", () => ({
30
+ S3Client: mockS3Client,
31
+ PutObjectCommand: mockPutObjectCommand,
32
+ DeleteObjectCommand: mockDeleteObjectCommand,
33
+ }));
34
+
35
+ vi.mock("@aws-sdk/s3-request-presigner", () => ({
36
+ getSignedUrl: mockGetSignedUrl,
37
+ }));
38
+
39
+ vi.mock("@vercel/blob/client", () => ({
40
+ handleUpload: mockHandleUpload,
41
+ }));
42
+
43
+ vi.mock("@vercel/blob", () => ({
44
+ del: mockDel,
45
+ }));
46
+
47
+ import { localAdapter } from "../api/adapters/local";
48
+
49
+ // ── Local adapter ─────────────────────────────────────────────────────────────
50
+
51
+ describe("localAdapter", () => {
52
+ let tmpDir: string;
53
+
54
+ afterEach(async () => {
55
+ if (tmpDir) {
56
+ await fs.rm(tmpDir, { recursive: true, force: true });
57
+ }
58
+ vi.clearAllMocks();
59
+ });
60
+
61
+ async function makeTmpDir() {
62
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "btst-media-test-"));
63
+ return tmpDir;
64
+ }
65
+
66
+ it("uploads a file and returns a public URL", async () => {
67
+ const uploadDir = await makeTmpDir();
68
+ const adapter = localAdapter({ uploadDir, publicPath: "/uploads" });
69
+
70
+ const buffer = Buffer.from("hello world");
71
+ const { url } = await adapter.upload(buffer, {
72
+ filename: "test.txt",
73
+ mimeType: "text/plain",
74
+ size: buffer.byteLength,
75
+ });
76
+
77
+ expect(url).toMatch(/^\/uploads\/test-[a-f0-9]{16}\.txt$/);
78
+
79
+ const storedFilename = url.split("/").pop()!;
80
+ const storedPath = path.join(uploadDir, storedFilename);
81
+ const storedContent = await fs.readFile(storedPath);
82
+ expect(storedContent.toString()).toBe("hello world");
83
+ });
84
+
85
+ it("creates the upload directory if it does not exist", async () => {
86
+ const uploadDir = path.join(await makeTmpDir(), "nested", "uploads");
87
+ const adapter = localAdapter({ uploadDir, publicPath: "/uploads" });
88
+
89
+ const buffer = Buffer.from("data");
90
+ await adapter.upload(buffer, {
91
+ filename: "file.txt",
92
+ mimeType: "text/plain",
93
+ size: buffer.byteLength,
94
+ });
95
+
96
+ const stat = await fs.stat(uploadDir);
97
+ expect(stat.isDirectory()).toBe(true);
98
+ });
99
+
100
+ it("generates a unique filename for each upload", async () => {
101
+ const uploadDir = await makeTmpDir();
102
+ const adapter = localAdapter({ uploadDir, publicPath: "/uploads" });
103
+
104
+ const buf = Buffer.from("data");
105
+ const options = {
106
+ filename: "same.jpg",
107
+ mimeType: "image/jpeg",
108
+ size: buf.byteLength,
109
+ };
110
+ const { url: url1 } = await adapter.upload(buf, options);
111
+ const { url: url2 } = await adapter.upload(buf, options);
112
+
113
+ expect(url1).not.toBe(url2);
114
+ });
115
+
116
+ it("deletes a previously uploaded file", async () => {
117
+ const uploadDir = await makeTmpDir();
118
+ const adapter = localAdapter({ uploadDir, publicPath: "/uploads" });
119
+
120
+ const buffer = Buffer.from("delete me");
121
+ const { url } = await adapter.upload(buffer, {
122
+ filename: "todelete.txt",
123
+ mimeType: "text/plain",
124
+ size: buffer.byteLength,
125
+ });
126
+
127
+ const storedFilename = url.split("/").pop()!;
128
+ const storedPath = path.join(uploadDir, storedFilename);
129
+
130
+ await adapter.delete(url);
131
+ await expect(fs.stat(storedPath)).rejects.toThrow();
132
+ });
133
+
134
+ it("does not throw when deleting a file that does not exist", async () => {
135
+ const uploadDir = await makeTmpDir();
136
+ const adapter = localAdapter({ uploadDir, publicPath: "/uploads" });
137
+
138
+ await expect(
139
+ adapter.delete("/uploads/nonexistent-file.jpg"),
140
+ ).resolves.not.toThrow();
141
+ });
142
+
143
+ it("uses default uploadDir and publicPath", async () => {
144
+ const adapter = localAdapter();
145
+ expect(adapter.type).toBe("local");
146
+ });
147
+ });
148
+
149
+ // ── S3 adapter ────────────────────────────────────────────────────────────────
150
+
151
+ describe("s3Adapter", () => {
152
+ afterEach(() => {
153
+ vi.clearAllMocks();
154
+ });
155
+
156
+ it("generates a presigned PUT URL token", async () => {
157
+ const { s3Adapter } = await import("../api/adapters/s3");
158
+ const adapter = s3Adapter({
159
+ bucket: "my-bucket",
160
+ region: "us-east-1",
161
+ accessKeyId: "ACCESS_KEY",
162
+ secretAccessKey: "SECRET_KEY",
163
+ publicBaseUrl: "https://assets.example.com",
164
+ });
165
+
166
+ const token = await adapter.generateUploadToken({
167
+ filename: "photo.jpg",
168
+ mimeType: "image/jpeg",
169
+ size: 4096,
170
+ });
171
+
172
+ expect(token.type).toBe("presigned-url");
173
+ expect(token.payload).toMatchObject({
174
+ uploadUrl: "https://s3.example.com/signed-url",
175
+ publicUrl: "https://assets.example.com/photo.jpg",
176
+ key: "photo.jpg",
177
+ method: "PUT",
178
+ headers: { "Content-Type": "image/jpeg" },
179
+ });
180
+ expect(mockPutObjectCommand).toHaveBeenCalledWith(
181
+ expect.objectContaining({
182
+ Bucket: "my-bucket",
183
+ Key: "photo.jpg",
184
+ ContentType: "image/jpeg",
185
+ }),
186
+ );
187
+ expect(mockGetSignedUrl).toHaveBeenCalledWith(
188
+ expect.any(Object),
189
+ expect.any(Object),
190
+ { expiresIn: 300 },
191
+ );
192
+ });
193
+
194
+ it("includes folderId in the S3 key", async () => {
195
+ const { s3Adapter } = await import("../api/adapters/s3");
196
+ const adapter = s3Adapter({
197
+ bucket: "my-bucket",
198
+ region: "us-east-1",
199
+ accessKeyId: "ACCESS_KEY",
200
+ secretAccessKey: "SECRET_KEY",
201
+ publicBaseUrl: "https://assets.example.com",
202
+ });
203
+
204
+ const token = await adapter.generateUploadToken({
205
+ filename: "image.png",
206
+ mimeType: "image/png",
207
+ size: 1000,
208
+ folderId: "folder-abc",
209
+ });
210
+
211
+ expect((token.payload as Record<string, unknown>).key).toBe(
212
+ "folder-abc/image.png",
213
+ );
214
+ expect((token.payload as Record<string, unknown>).publicUrl).toBe(
215
+ "https://assets.example.com/folder-abc/image.png",
216
+ );
217
+ });
218
+
219
+ it("calls DeleteObjectCommand with the correct key when deleting by public URL", async () => {
220
+ const { s3Adapter } = await import("../api/adapters/s3");
221
+ const adapter = s3Adapter({
222
+ bucket: "my-bucket",
223
+ region: "us-east-1",
224
+ accessKeyId: "ACCESS_KEY",
225
+ secretAccessKey: "SECRET_KEY",
226
+ publicBaseUrl: "https://assets.example.com",
227
+ });
228
+
229
+ await adapter.delete("https://assets.example.com/photos/cat.jpg");
230
+
231
+ expect(mockDeleteObjectCommand).toHaveBeenCalledWith({
232
+ Bucket: "my-bucket",
233
+ Key: "photos/cat.jpg",
234
+ });
235
+ expect(mockSend).toHaveBeenCalled();
236
+ });
237
+
238
+ it("respects the custom expiresIn option", async () => {
239
+ const { s3Adapter } = await import("../api/adapters/s3");
240
+ const adapter = s3Adapter({
241
+ bucket: "my-bucket",
242
+ region: "us-east-1",
243
+ accessKeyId: "ACCESS_KEY",
244
+ secretAccessKey: "SECRET_KEY",
245
+ publicBaseUrl: "https://assets.example.com",
246
+ expiresIn: 600,
247
+ });
248
+
249
+ await adapter.generateUploadToken({
250
+ filename: "file.jpg",
251
+ mimeType: "image/jpeg",
252
+ size: 100,
253
+ });
254
+
255
+ expect(mockGetSignedUrl).toHaveBeenCalledWith(
256
+ expect.any(Object),
257
+ expect.any(Object),
258
+ { expiresIn: 600 },
259
+ );
260
+ });
261
+ });
262
+
263
+ // ── Vercel Blob adapter ───────────────────────────────────────────────────────
264
+
265
+ describe("vercelBlobAdapter", () => {
266
+ afterEach(() => {
267
+ vi.clearAllMocks();
268
+ });
269
+
270
+ it("calls handleUpload with the request and body", async () => {
271
+ const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob");
272
+ const adapter = vercelBlobAdapter();
273
+
274
+ const body = {
275
+ type: "blob.generate-client-token",
276
+ payload: { pathname: "photo.jpg" },
277
+ };
278
+ const request = new Request("https://example.com/api/upload", {
279
+ method: "POST",
280
+ body: JSON.stringify(body),
281
+ headers: { "Content-Type": "application/json" },
282
+ });
283
+
284
+ const result = await adapter.handleRequest(request, {});
285
+
286
+ expect(mockHandleUpload).toHaveBeenCalledWith(
287
+ expect.objectContaining({
288
+ body,
289
+ request,
290
+ }),
291
+ );
292
+ expect(result).toEqual({
293
+ type: "blob.generate-client-token",
294
+ clientToken: "tok123",
295
+ });
296
+ });
297
+
298
+ it("passes the onBeforeGenerateToken callback to handleUpload", async () => {
299
+ const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob");
300
+ const adapter = vercelBlobAdapter();
301
+
302
+ const onBeforeGenerateToken = vi.fn().mockResolvedValue(undefined);
303
+ const body = {
304
+ type: "blob.generate-client-token",
305
+ payload: { pathname: "test.jpg" },
306
+ };
307
+ const request = new Request("https://example.com/api/upload", {
308
+ method: "POST",
309
+ body: JSON.stringify(body),
310
+ headers: { "Content-Type": "application/json" },
311
+ });
312
+
313
+ await adapter.handleRequest(request, { onBeforeGenerateToken });
314
+
315
+ // Verify that handleUpload received an onBeforeGenerateToken callback
316
+ const callArgs = mockHandleUpload.mock.calls[0]![0] as Record<
317
+ string,
318
+ unknown
319
+ >;
320
+ expect(callArgs.onBeforeGenerateToken).toBeTypeOf("function");
321
+
322
+ // Invoke the callback directly to verify it proxies to our hook
323
+ const cb = callArgs.onBeforeGenerateToken as Function;
324
+ await cb("test.jpg", null);
325
+ expect(onBeforeGenerateToken).toHaveBeenCalledWith("test.jpg", null);
326
+ });
327
+
328
+ it("calls del when deleting a blob by URL", async () => {
329
+ const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob");
330
+ const adapter = vercelBlobAdapter();
331
+
332
+ await adapter.delete("https://public.blob.vercel-storage.com/photo.jpg");
333
+
334
+ expect(mockDel).toHaveBeenCalledWith(
335
+ "https://public.blob.vercel-storage.com/photo.jpg",
336
+ undefined,
337
+ );
338
+ });
339
+
340
+ it("passes the token option to del when provided", async () => {
341
+ const { vercelBlobAdapter } = await import("../api/adapters/vercel-blob");
342
+ const adapter = vercelBlobAdapter({ token: "my-custom-token" });
343
+
344
+ await adapter.delete("https://public.blob.vercel-storage.com/file.jpg");
345
+
346
+ expect(mockDel).toHaveBeenCalledWith(
347
+ "https://public.blob.vercel-storage.com/file.jpg",
348
+ { token: "my-custom-token" },
349
+ );
350
+ });
351
+ });
@@ -0,0 +1,79 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
4
+ import type { DirectStorageAdapter, UploadOptions } from "../storage-adapter";
5
+
6
+ export interface LocalStorageAdapterOptions {
7
+ /**
8
+ * Absolute path to the directory where uploaded files are stored.
9
+ * @default "./public/uploads"
10
+ */
11
+ uploadDir?: string;
12
+ /**
13
+ * URL prefix used to build the public URL for uploaded files.
14
+ * @default "/uploads"
15
+ */
16
+ publicPath?: string;
17
+ }
18
+
19
+ /**
20
+ * Create a local filesystem storage adapter.
21
+ * Files are written to `uploadDir` and served at `publicPath`.
22
+ * Suitable for development and self-hosted deployments.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * mediaBackendPlugin({
27
+ * storageAdapter: localAdapter({ uploadDir: "./public/uploads", publicPath: "/uploads" })
28
+ * })
29
+ * ```
30
+ */
31
+ export function localAdapter(
32
+ options: LocalStorageAdapterOptions = {},
33
+ ): DirectStorageAdapter {
34
+ const uploadDir = options.uploadDir ?? "./public/uploads";
35
+ const publicPath = options.publicPath ?? "/uploads";
36
+
37
+ return {
38
+ type: "local" as const,
39
+
40
+ async upload(
41
+ buffer: Buffer,
42
+ { filename }: UploadOptions,
43
+ ): Promise<{ url: string }> {
44
+ await fs.mkdir(uploadDir, { recursive: true });
45
+
46
+ const ext = path.extname(filename);
47
+ const base = path.basename(filename, ext);
48
+ const unique = crypto.randomBytes(8).toString("hex");
49
+ const storedFilename = `${base}-${unique}${ext}`;
50
+ const filePath = path.join(uploadDir, storedFilename);
51
+
52
+ await fs.writeFile(filePath, buffer);
53
+
54
+ // Percent-encode the filename segment so the returned URL is always a
55
+ // valid URL — e.g. spaces become %20. The raw storedFilename is used for
56
+ // the filesystem path; the encoded form is what gets stored in the DB and
57
+ // served to clients.
58
+ const url = `${publicPath.replace(/\/$/, "")}/${encodeURIComponent(storedFilename)}`;
59
+ return { url };
60
+ },
61
+
62
+ async delete(url: string): Promise<void> {
63
+ // The stored URL has an encoded filename (e.g. "my%20file.png"); decode
64
+ // it back to the raw filesystem name before building the file path.
65
+ const encodedFilename = url.split("/").pop();
66
+ if (!encodedFilename) return;
67
+ const filename = decodeURIComponent(encodedFilename);
68
+
69
+ const filePath = path.join(uploadDir, filename);
70
+ try {
71
+ await fs.unlink(filePath);
72
+ } catch (err: unknown) {
73
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
74
+ throw err;
75
+ }
76
+ }
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,198 @@
1
+ import type {
2
+ S3StorageAdapter,
3
+ S3UploadToken,
4
+ UploadOptions,
5
+ } from "../storage-adapter";
6
+
7
+ export interface S3StorageAdapterOptions {
8
+ /**
9
+ * The S3 bucket name.
10
+ */
11
+ bucket: string;
12
+ /**
13
+ * AWS region (e.g. `"us-east-1"`).
14
+ */
15
+ region: string;
16
+ /**
17
+ * AWS access key ID.
18
+ */
19
+ accessKeyId: string;
20
+ /**
21
+ * AWS secret access key.
22
+ */
23
+ secretAccessKey: string;
24
+ /**
25
+ * Custom endpoint URL for S3-compatible providers (Cloudflare R2, MinIO, etc.).
26
+ * @example "https://<account-id>.r2.cloudflarestorage.com"
27
+ */
28
+ endpoint?: string;
29
+ /**
30
+ * Base URL used to construct the final public asset URL after upload.
31
+ * @example "https://assets.example.com" or "https://pub-<id>.r2.dev"
32
+ */
33
+ publicBaseUrl: string;
34
+ /**
35
+ * Duration in seconds for which the presigned URL is valid.
36
+ * @default 300 (5 minutes)
37
+ */
38
+ expiresIn?: number;
39
+ }
40
+
41
+ /**
42
+ * Create an S3-compatible presigned-URL storage adapter.
43
+ * The server generates a short-lived presigned PUT URL; the browser uploads
44
+ * the file directly to S3 (or R2 / MinIO). The server never receives file bytes.
45
+ *
46
+ * @remarks Requires `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`
47
+ * as optional peer dependencies.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * mediaBackendPlugin({
52
+ * storageAdapter: s3Adapter({
53
+ * bucket: "my-bucket",
54
+ * region: "us-east-1",
55
+ * accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
56
+ * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
57
+ * publicBaseUrl: "https://assets.example.com",
58
+ * })
59
+ * })
60
+ * ```
61
+ */
62
+ export function s3Adapter(options: S3StorageAdapterOptions): S3StorageAdapter {
63
+ const {
64
+ bucket,
65
+ region,
66
+ accessKeyId,
67
+ secretAccessKey,
68
+ endpoint,
69
+ publicBaseUrl,
70
+ expiresIn = 300,
71
+ } = options;
72
+
73
+ let s3ModulePromise: Promise<typeof import("@aws-sdk/client-s3")> | null =
74
+ null;
75
+ let clientPromise: Promise<
76
+ InstanceType<typeof import("@aws-sdk/client-s3").S3Client>
77
+ > | null = null;
78
+
79
+ function getS3Module() {
80
+ if (!s3ModulePromise) {
81
+ s3ModulePromise = import("@aws-sdk/client-s3").catch(() => {
82
+ s3ModulePromise = null;
83
+ throw new Error(
84
+ "[@btst/stack] S3 adapter requires '@aws-sdk/client-s3'. " +
85
+ "Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner",
86
+ );
87
+ });
88
+ }
89
+ return s3ModulePromise;
90
+ }
91
+
92
+ function getClient() {
93
+ if (!clientPromise) {
94
+ clientPromise = getS3Module().then(({ S3Client }) => {
95
+ return new S3Client({
96
+ region,
97
+ endpoint,
98
+ credentials: { accessKeyId, secretAccessKey },
99
+ forcePathStyle: !!endpoint,
100
+ });
101
+ });
102
+ }
103
+ return clientPromise.catch((error) => {
104
+ if (clientPromise) {
105
+ clientPromise = null;
106
+ }
107
+ if (s3ModulePromise && error instanceof Error) {
108
+ throw error;
109
+ }
110
+ throw new Error(
111
+ "[@btst/stack] S3 adapter requires '@aws-sdk/client-s3'. " +
112
+ "Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner",
113
+ );
114
+ });
115
+ }
116
+
117
+ async function buildSignedUrl(
118
+ client: unknown,
119
+ command: unknown,
120
+ opts: { expiresIn: number },
121
+ ): Promise<string> {
122
+ let getSignedUrl: typeof import("@aws-sdk/s3-request-presigner")["getSignedUrl"];
123
+ try {
124
+ ({ getSignedUrl } = await import("@aws-sdk/s3-request-presigner"));
125
+ } catch {
126
+ throw new Error(
127
+ "[@btst/stack] S3 adapter requires '@aws-sdk/s3-request-presigner'. " +
128
+ "Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner",
129
+ );
130
+ }
131
+ return getSignedUrl(
132
+ client as Parameters<typeof getSignedUrl>[0],
133
+ command as Parameters<typeof getSignedUrl>[1],
134
+ opts,
135
+ );
136
+ }
137
+
138
+ return {
139
+ type: "s3" as const,
140
+ urlPrefix: publicBaseUrl.replace(/\/$/, ""),
141
+
142
+ async generateUploadToken(
143
+ uploadOptions: UploadOptions,
144
+ ): Promise<S3UploadToken> {
145
+ const [client, { PutObjectCommand }] = await Promise.all([
146
+ getClient(),
147
+ getS3Module(),
148
+ ]);
149
+
150
+ const key = uploadOptions.folderId
151
+ ? `${uploadOptions.folderId}/${uploadOptions.filename}`
152
+ : uploadOptions.filename;
153
+
154
+ const command = new PutObjectCommand({
155
+ Bucket: bucket,
156
+ Key: key,
157
+ ContentType: uploadOptions.mimeType,
158
+ ContentLength: uploadOptions.size,
159
+ });
160
+
161
+ const uploadUrl = await buildSignedUrl(client, command, { expiresIn });
162
+
163
+ // Percent-encode each path segment so the stored public URL is always
164
+ // valid. The raw `key` is used for the S3 key (which the AWS SDK
165
+ // handles separately); the encoded form is what gets stored in the DB
166
+ // and returned to clients.
167
+ const encodedKey = key.split("/").map(encodeURIComponent).join("/");
168
+ const publicUrl = `${publicBaseUrl.replace(/\/$/, "")}/${encodedKey}`;
169
+
170
+ return {
171
+ type: "presigned-url",
172
+ payload: {
173
+ uploadUrl,
174
+ publicUrl,
175
+ key,
176
+ method: "PUT" as const,
177
+ headers: { "Content-Type": uploadOptions.mimeType },
178
+ },
179
+ };
180
+ },
181
+
182
+ async delete(url: string): Promise<void> {
183
+ const [client, { DeleteObjectCommand }] = await Promise.all([
184
+ getClient(),
185
+ getS3Module(),
186
+ ]);
187
+
188
+ const base = publicBaseUrl.replace(/\/$/, "");
189
+ const encodedKey = url.startsWith(base)
190
+ ? url.slice(base.length + 1)
191
+ : (url.split("/").pop() ?? url);
192
+ // Decode the percent-encoded key back to the raw S3 object key.
193
+ const key = decodeURIComponent(encodedKey);
194
+
195
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
196
+ },
197
+ };
198
+ }