@howells/stow-next 0.1.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/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @howells/stow-next
2
+
3
+ Next.js integration for [Stow](https://stow.sh) file storage. Includes route handler helpers and an image loader for `next/image`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @howells/stow-next @howells/stow-server
9
+ # or
10
+ pnpm add @howells/stow-next @howells/stow-server
11
+ # or
12
+ yarn add @howells/stow-next @howells/stow-server
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ### 1. Create an upload route
18
+
19
+ ```typescript
20
+ // app/api/upload/route.ts
21
+ import { createUploadHandler } from "@howells/stow-next";
22
+ import { StowServer } from "@howells/stow-server";
23
+
24
+ const stow = new StowServer(process.env.STOW_API_KEY!);
25
+
26
+ export const POST = createUploadHandler({
27
+ stow,
28
+ maxSize: 10 * 1024 * 1024, // 10MB
29
+ allowedTypes: ["image/*", "application/pdf"],
30
+ });
31
+ ```
32
+
33
+ ### 2. Use with @howells/stow-react
34
+
35
+ ```tsx
36
+ // app/page.tsx
37
+ import { UploadDropzone } from "@howells/stow-react";
38
+
39
+ export default function Page() {
40
+ return (
41
+ <UploadDropzone
42
+ endpoint="/api/upload"
43
+ onUploadComplete={(files) => console.log(files)}
44
+ />
45
+ );
46
+ }
47
+ ```
48
+
49
+ ## Route Handler
50
+
51
+ ### `createUploadHandler(config)`
52
+
53
+ Creates a Next.js route handler for file uploads.
54
+
55
+ ```typescript
56
+ import { createUploadHandler } from "@howells/stow-next";
57
+
58
+ export const POST = createUploadHandler({
59
+ // Required: Stow server instance
60
+ stow: new StowServer(process.env.STOW_API_KEY!),
61
+
62
+ // Optional: File restrictions
63
+ maxSize: 10 * 1024 * 1024, // Max file size in bytes
64
+ allowedTypes: ["image/*", ".pdf"], // Allowed MIME types or extensions
65
+ route: "uploads", // Default route/folder
66
+
67
+ // Optional: Custom validation
68
+ validate: async (file) => {
69
+ if (file.name.includes("secret")) {
70
+ return "Filename not allowed";
71
+ }
72
+ return true;
73
+ },
74
+
75
+ // Optional: Lifecycle hooks
76
+ onUploadBegin: async (file) => {
77
+ console.log(`Starting upload: ${file.name}`);
78
+ },
79
+ onUploadComplete: async (result) => {
80
+ console.log(`Uploaded: ${result.url}`);
81
+ // Save to database, etc.
82
+ },
83
+ });
84
+ ```
85
+
86
+ ## Image Loader
87
+
88
+ Use Stow's image transformation with Next.js Image component.
89
+
90
+ ### Setup
91
+
92
+ ```javascript
93
+ // next.config.js
94
+ module.exports = {
95
+ images: {
96
+ loader: "custom",
97
+ loaderFile: "./lib/stow-loader.ts",
98
+ },
99
+ };
100
+ ```
101
+
102
+ ```typescript
103
+ // lib/stow-loader.ts
104
+ import { stowLoader } from "@howells/stow-next/image-loader";
105
+ export default stowLoader;
106
+ ```
107
+
108
+ ### Usage
109
+
110
+ ```tsx
111
+ import Image from "next/image";
112
+
113
+ function MyComponent() {
114
+ return (
115
+ <Image
116
+ src="https://stow.sh/files/bucket-id/image.jpg"
117
+ alt="My image"
118
+ width={800}
119
+ height={600}
120
+ quality={80}
121
+ />
122
+ );
123
+ }
124
+ ```
125
+
126
+ ### Custom Loader Config
127
+
128
+ ```typescript
129
+ // lib/stow-loader.ts
130
+ import { createStowLoader } from "@howells/stow-next/image-loader";
131
+
132
+ export default createStowLoader({
133
+ baseUrl: "https://stow.sh",
134
+ defaultQuality: 80,
135
+ defaultFormat: "webp",
136
+ });
137
+ ```
138
+
139
+ ## TypeScript
140
+
141
+ ```typescript
142
+ import type {
143
+ UploadHandlerConfig,
144
+ StowLoaderConfig,
145
+ } from "@howells/stow-next";
146
+ ```
147
+
148
+ ## Complete Example
149
+
150
+ ```typescript
151
+ // app/api/upload/route.ts
152
+ import { createUploadHandler } from "@howells/stow-next";
153
+ import { StowServer } from "@howells/stow-server";
154
+ import { db } from "@/lib/db";
155
+ import { auth } from "@/lib/auth";
156
+
157
+ const stow = new StowServer(process.env.STOW_API_KEY!);
158
+
159
+ export const POST = createUploadHandler({
160
+ stow,
161
+ maxSize: 5 * 1024 * 1024,
162
+ allowedTypes: ["image/jpeg", "image/png", "image/webp"],
163
+
164
+ validate: async (file) => {
165
+ const session = await auth();
166
+ if (!session) return "Unauthorized";
167
+ return true;
168
+ },
169
+
170
+ onUploadComplete: async (result) => {
171
+ const session = await auth();
172
+ await db.insert(files).values({
173
+ key: result.key,
174
+ url: result.url,
175
+ userId: session!.user.id,
176
+ });
177
+ },
178
+ });
179
+ ```
180
+
181
+ ## License
182
+
183
+ MIT
@@ -0,0 +1,39 @@
1
+ // src/image-loader.ts
2
+ function createStowLoader(config = {}) {
3
+ const {
4
+ baseUrl = "https://stow.sh",
5
+ defaultQuality = 75,
6
+ defaultFormat
7
+ } = config;
8
+ return function stowLoader2({
9
+ src,
10
+ width,
11
+ quality
12
+ }) {
13
+ if (src.startsWith(baseUrl) || src.startsWith("/files/")) {
14
+ const url = new URL(src, baseUrl);
15
+ const pathname = url.pathname;
16
+ const params = new URLSearchParams();
17
+ params.set("w", width.toString());
18
+ params.set("q", (quality || defaultQuality).toString());
19
+ if (defaultFormat) {
20
+ params.set("f", defaultFormat);
21
+ }
22
+ if (pathname.startsWith("/files/")) {
23
+ const transformPath = pathname.replace("/files/", "/transform/");
24
+ return `${baseUrl}${transformPath}?${params.toString()}`;
25
+ }
26
+ if (pathname.startsWith("/transform/")) {
27
+ return `${baseUrl}${pathname}?${params.toString()}`;
28
+ }
29
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
30
+ }
31
+ return src;
32
+ };
33
+ }
34
+ var stowLoader = createStowLoader();
35
+
36
+ export {
37
+ createStowLoader,
38
+ stowLoader
39
+ };
@@ -0,0 +1,87 @@
1
+ // src/image-loader.ts
2
+ var STOW_DOMAIN_PATTERN = /\.stow\.sh$/;
3
+ function isStowUrl(src) {
4
+ try {
5
+ const url = new URL(src);
6
+ return STOW_DOMAIN_PATTERN.test(url.hostname);
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+ function buildTransformParams(width, quality, config) {
12
+ const params = new URLSearchParams();
13
+ params.set("w", width.toString());
14
+ if (config.aspectRatio) {
15
+ params.set("h", Math.round(width / config.aspectRatio).toString());
16
+ }
17
+ params.set("q", quality.toString());
18
+ if (config.fit) {
19
+ params.set("fit", config.fit);
20
+ }
21
+ if (config.gravity) {
22
+ params.set("gravity", config.gravity);
23
+ }
24
+ if (config.defaultFormat) {
25
+ params.set("f", config.defaultFormat);
26
+ }
27
+ return params;
28
+ }
29
+ function transformStowUrl(src, baseUrl, params) {
30
+ const url = new URL(src, baseUrl);
31
+ const pathname = url.pathname;
32
+ if (pathname.startsWith("/files/")) {
33
+ const transformPath = pathname.replace("/files/", "/transform/");
34
+ return `${baseUrl}${transformPath}?${params.toString()}`;
35
+ }
36
+ if (pathname.startsWith("/transform/")) {
37
+ return `${baseUrl}${pathname}?${params.toString()}`;
38
+ }
39
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
40
+ }
41
+ function createStowLoader(config = {}) {
42
+ const {
43
+ baseUrl = "https://stow.sh",
44
+ defaultQuality = 75,
45
+ defaultFormat,
46
+ proxySlug,
47
+ fit,
48
+ gravity,
49
+ aspectRatio
50
+ } = config;
51
+ return function stowLoader2({
52
+ src,
53
+ width,
54
+ quality
55
+ }) {
56
+ const resolvedQuality = quality || defaultQuality;
57
+ const paramConfig = { defaultFormat, fit, gravity, aspectRatio };
58
+ if (isStowUrl(src)) {
59
+ const params = buildTransformParams(width, resolvedQuality, paramConfig);
60
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
61
+ }
62
+ if (src.startsWith(baseUrl) || src.startsWith("/files/")) {
63
+ const params = buildTransformParams(width, resolvedQuality, paramConfig);
64
+ return transformStowUrl(src, baseUrl, params);
65
+ }
66
+ if (proxySlug && (src.startsWith("http://") || src.startsWith("https://"))) {
67
+ const params = new URLSearchParams();
68
+ params.set("url", src);
69
+ params.set("w", width.toString());
70
+ if (aspectRatio) {
71
+ params.set("h", Math.round(width / aspectRatio).toString());
72
+ }
73
+ params.set("q", resolvedQuality.toString());
74
+ if (fit) params.set("fit", fit);
75
+ if (gravity) params.set("gravity", gravity);
76
+ if (defaultFormat) params.set("f", defaultFormat);
77
+ return `https://proxy.stow.sh/${proxySlug}/?${params.toString()}`;
78
+ }
79
+ return src;
80
+ };
81
+ }
82
+ var stowLoader = createStowLoader();
83
+
84
+ export {
85
+ createStowLoader,
86
+ stowLoader
87
+ };
@@ -0,0 +1,31 @@
1
+ // src/image-loader.ts
2
+ function createObliqLoader(config = {}) {
3
+ const { baseUrl = "https://obliq.co", defaultQuality = 75, defaultFormat } = config;
4
+ return function obliqLoader2({ src, width, quality }) {
5
+ if (src.startsWith(baseUrl) || src.startsWith("/files/")) {
6
+ const url = new URL(src, baseUrl);
7
+ const pathname = url.pathname;
8
+ const params = new URLSearchParams();
9
+ params.set("w", width.toString());
10
+ params.set("q", (quality || defaultQuality).toString());
11
+ if (defaultFormat) {
12
+ params.set("f", defaultFormat);
13
+ }
14
+ if (pathname.startsWith("/files/")) {
15
+ const transformPath = pathname.replace("/files/", "/transform/");
16
+ return `${baseUrl}${transformPath}?${params.toString()}`;
17
+ }
18
+ if (pathname.startsWith("/transform/")) {
19
+ return `${baseUrl}${pathname}?${params.toString()}`;
20
+ }
21
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
22
+ }
23
+ return src;
24
+ };
25
+ }
26
+ var obliqLoader = createObliqLoader();
27
+
28
+ export {
29
+ createObliqLoader,
30
+ obliqLoader
31
+ };
@@ -0,0 +1,93 @@
1
+ // src/image-loader.ts
2
+ var STOW_DOMAIN_PATTERN = /\.stow\.sh$/;
3
+ function isStowUrl(src) {
4
+ try {
5
+ const url = new URL(src);
6
+ return STOW_DOMAIN_PATTERN.test(url.hostname);
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+ function buildTransformParams(width, quality, config) {
12
+ const params = new URLSearchParams();
13
+ params.set("w", width.toString());
14
+ if (config.aspectRatio) {
15
+ params.set("h", Math.round(width / config.aspectRatio).toString());
16
+ }
17
+ params.set("q", quality.toString());
18
+ if (config.fit) {
19
+ params.set("fit", config.fit);
20
+ }
21
+ if (config.gravity) {
22
+ params.set("gravity", config.gravity);
23
+ }
24
+ if (config.defaultFormat) {
25
+ params.set("f", config.defaultFormat);
26
+ }
27
+ return params;
28
+ }
29
+ function transformStowUrl(src, baseUrl, params) {
30
+ const url = new URL(src, baseUrl);
31
+ const pathname = url.pathname;
32
+ if (pathname.startsWith("/files/")) {
33
+ const transformPath = pathname.replace("/files/", "/transform/");
34
+ return `${baseUrl}${transformPath}?${params.toString()}`;
35
+ }
36
+ if (pathname.startsWith("/transform/")) {
37
+ return `${baseUrl}${pathname}?${params.toString()}`;
38
+ }
39
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
40
+ }
41
+ function createStowLoader(config = {}) {
42
+ const {
43
+ baseUrl = "https://stow.sh",
44
+ defaultQuality = 75,
45
+ defaultFormat,
46
+ proxySlug,
47
+ fit,
48
+ gravity,
49
+ aspectRatio
50
+ } = config;
51
+ return function stowLoader2({
52
+ src,
53
+ width,
54
+ quality
55
+ }) {
56
+ const resolvedQuality = quality || defaultQuality;
57
+ const paramConfig = { defaultFormat, fit, gravity, aspectRatio };
58
+ if (isStowUrl(src)) {
59
+ const params = buildTransformParams(width, resolvedQuality, paramConfig);
60
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
61
+ }
62
+ if (src.startsWith(baseUrl) || src.startsWith("/files/")) {
63
+ const params = buildTransformParams(width, resolvedQuality, paramConfig);
64
+ return transformStowUrl(src, baseUrl, params);
65
+ }
66
+ if (proxySlug && (src.startsWith("http://") || src.startsWith("https://"))) {
67
+ const params = new URLSearchParams();
68
+ params.set("url", src);
69
+ params.set("w", width.toString());
70
+ if (aspectRatio) {
71
+ params.set("h", Math.round(width / aspectRatio).toString());
72
+ }
73
+ params.set("q", resolvedQuality.toString());
74
+ if (fit) {
75
+ params.set("fit", fit);
76
+ }
77
+ if (gravity) {
78
+ params.set("gravity", gravity);
79
+ }
80
+ if (defaultFormat) {
81
+ params.set("f", defaultFormat);
82
+ }
83
+ return `https://proxy.stow.sh/${proxySlug}/?${params.toString()}`;
84
+ }
85
+ return src;
86
+ };
87
+ }
88
+ var stowLoader = createStowLoader();
89
+
90
+ export {
91
+ createStowLoader,
92
+ stowLoader
93
+ };
@@ -0,0 +1,62 @@
1
+ // src/image-loader.ts
2
+ function buildTransformParams(width, quality, defaultFormat) {
3
+ const params = new URLSearchParams();
4
+ params.set("w", width.toString());
5
+ params.set("q", quality.toString());
6
+ if (defaultFormat) {
7
+ params.set("f", defaultFormat);
8
+ }
9
+ return params;
10
+ }
11
+ function transformStowUrl(src, baseUrl, params) {
12
+ const url = new URL(src, baseUrl);
13
+ const pathname = url.pathname;
14
+ if (pathname.startsWith("/files/")) {
15
+ const transformPath = pathname.replace("/files/", "/transform/");
16
+ return `${baseUrl}${transformPath}?${params.toString()}`;
17
+ }
18
+ if (pathname.startsWith("/transform/")) {
19
+ return `${baseUrl}${pathname}?${params.toString()}`;
20
+ }
21
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
22
+ }
23
+ function createStowLoader(config = {}) {
24
+ const {
25
+ baseUrl = "https://stow.sh",
26
+ defaultQuality = 75,
27
+ defaultFormat,
28
+ proxySlug
29
+ } = config;
30
+ return function stowLoader2({
31
+ src,
32
+ width,
33
+ quality
34
+ }) {
35
+ const resolvedQuality = quality || defaultQuality;
36
+ if (src.startsWith(baseUrl) || src.startsWith("/files/")) {
37
+ const params = buildTransformParams(
38
+ width,
39
+ resolvedQuality,
40
+ defaultFormat
41
+ );
42
+ return transformStowUrl(src, baseUrl, params);
43
+ }
44
+ if (proxySlug && (src.startsWith("http://") || src.startsWith("https://"))) {
45
+ const params = new URLSearchParams();
46
+ params.set("url", src);
47
+ params.set("w", width.toString());
48
+ params.set("q", resolvedQuality.toString());
49
+ if (defaultFormat) {
50
+ params.set("f", defaultFormat);
51
+ }
52
+ return `https://proxy.stow.sh/${proxySlug}/?${params.toString()}`;
53
+ }
54
+ return src;
55
+ };
56
+ }
57
+ var stowLoader = createStowLoader();
58
+
59
+ export {
60
+ createStowLoader,
61
+ stowLoader
62
+ };
@@ -0,0 +1,31 @@
1
+ // src/image-loader.ts
2
+ function createStowLoader(config = {}) {
3
+ const { baseUrl = "https://stow.sh", defaultQuality = 75, defaultFormat } = config;
4
+ return function stowLoader2({ src, width, quality }) {
5
+ if (src.startsWith(baseUrl) || src.startsWith("/files/")) {
6
+ const url = new URL(src, baseUrl);
7
+ const pathname = url.pathname;
8
+ const params = new URLSearchParams();
9
+ params.set("w", width.toString());
10
+ params.set("q", (quality || defaultQuality).toString());
11
+ if (defaultFormat) {
12
+ params.set("f", defaultFormat);
13
+ }
14
+ if (pathname.startsWith("/files/")) {
15
+ const transformPath = pathname.replace("/files/", "/transform/");
16
+ return `${baseUrl}${transformPath}?${params.toString()}`;
17
+ }
18
+ if (pathname.startsWith("/transform/")) {
19
+ return `${baseUrl}${pathname}?${params.toString()}`;
20
+ }
21
+ return `${src}${src.includes("?") ? "&" : "?"}${params.toString()}`;
22
+ }
23
+ return src;
24
+ };
25
+ }
26
+ var stowLoader = createStowLoader();
27
+
28
+ export {
29
+ createStowLoader,
30
+ stowLoader
31
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Stow Image Loader for Next.js
3
+ *
4
+ * Use Stow's image transformation service with Next.js Image component.
5
+ * Supports both vanity subdomain URLs ({slug}.stow.sh/{key}) and
6
+ * legacy path-based URLs (stow.sh/files/{bucketId}/{key}).
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // next.config.js — global loader
11
+ * module.exports = {
12
+ * images: {
13
+ * loader: "custom",
14
+ * loaderFile: "./lib/stow-loader.ts",
15
+ * },
16
+ * };
17
+ *
18
+ * // lib/stow-loader.ts
19
+ * import { stowLoader } from "@howells/stow-next/image-loader";
20
+ * export default stowLoader;
21
+ * ```
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * // Per-component loader with crop options
26
+ * import { createStowLoader } from "@howells/stow-next/image-loader";
27
+ * import Image from "next/image";
28
+ *
29
+ * const avatarLoader = createStowLoader({
30
+ * fit: "cover",
31
+ * gravity: "face",
32
+ * aspectRatio: 1,
33
+ * defaultFormat: "webp",
34
+ * });
35
+ *
36
+ * function Avatar({ src }: { src: string }) {
37
+ * return <Image loader={avatarLoader} src={src} alt="Avatar" width={128} height={128} />;
38
+ * }
39
+ * ```
40
+ */
41
+ interface ImageLoaderProps {
42
+ quality?: number;
43
+ src: string;
44
+ width: number;
45
+ }
46
+ interface StowLoaderConfig {
47
+ /**
48
+ * Fixed aspect ratio (width / height).
49
+ * When set, height is calculated from the requested width.
50
+ * Example: 1 for square, 16/9 for widescreen, 4/5 for portrait.
51
+ */
52
+ aspectRatio?: number;
53
+ /**
54
+ * Base URL for the Stow service.
55
+ * @default "https://stow.sh"
56
+ */
57
+ baseUrl?: string;
58
+ /**
59
+ * Default output format.
60
+ */
61
+ defaultFormat?: "webp" | "avif" | "jpeg" | "png";
62
+ /**
63
+ * Default image quality (1-100).
64
+ * @default 75
65
+ */
66
+ defaultQuality?: number;
67
+ /**
68
+ * Resize fit mode (Cloudflare Images).
69
+ * - "scale-down" — shrink to fit, never enlarge
70
+ * - "contain" — fit within bounds preserving aspect ratio
71
+ * - "cover" — fill bounds, crop excess
72
+ * - "crop" — crop to exact dimensions
73
+ * - "pad" — fit within bounds, pad remaining space
74
+ */
75
+ fit?: "scale-down" | "contain" | "cover" | "crop" | "pad";
76
+ /**
77
+ * Crop gravity / anchor point.
78
+ * - "auto" — subject-aware ML cropping
79
+ * - "face" — face-detection cropping
80
+ * - directional: "left", "right", "top", "bottom", "center"
81
+ */
82
+ gravity?: "auto" | "face" | "left" | "right" | "top" | "bottom" | "center";
83
+ /**
84
+ * Bucket slug for proxying external images through Stow.
85
+ * When set, external URLs (http/https) are routed through proxy.stow.sh
86
+ * for R2 caching and on-the-fly image transforms.
87
+ */
88
+ proxySlug?: string;
89
+ }
90
+ /**
91
+ * Create a custom image loader with configuration.
92
+ *
93
+ * Returns a stable function reference safe for use as a Next.js `loader` prop.
94
+ */
95
+ declare function createStowLoader(config?: StowLoaderConfig): ({ src, width, quality, }: ImageLoaderProps) => string;
96
+ /**
97
+ * Default Stow image loader.
98
+ *
99
+ * Handles vanity subdomain URLs ({slug}.stow.sh) and legacy
100
+ * path-based URLs (stow.sh/files/...). Appends width and quality
101
+ * as transform query params.
102
+ *
103
+ * ```js
104
+ * // next.config.js
105
+ * module.exports = {
106
+ * images: {
107
+ * loader: "custom",
108
+ * loaderFile: "./lib/stow-loader.ts",
109
+ * },
110
+ * };
111
+ *
112
+ * // lib/stow-loader.ts
113
+ * import { stowLoader } from "@howells/stow-next/image-loader";
114
+ * export default stowLoader;
115
+ * ```
116
+ */
117
+ declare const stowLoader: ({ src, width, quality, }: ImageLoaderProps) => string;
118
+
119
+ export { type ImageLoaderProps, type StowLoaderConfig, createStowLoader, stowLoader };