@convex-dev/static-hosting 0.1.2-beta.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/LICENSE +201 -0
- package/README.md +333 -0
- package/dist/cli/deploy.d.ts +16 -0
- package/dist/cli/deploy.d.ts.map +1 -0
- package/dist/cli/deploy.js +324 -0
- package/dist/cli/deploy.js.map +1 -0
- package/dist/cli/index.d.ts +15 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +95 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +9 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +181 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/next-build.d.ts +24 -0
- package/dist/cli/next-build.d.ts.map +1 -0
- package/dist/cli/next-build.js +569 -0
- package/dist/cli/next-build.js.map +1 -0
- package/dist/cli/setup.d.ts +9 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +157 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/upload.d.ts +15 -0
- package/dist/cli/upload.d.ts.map +1 -0
- package/dist/cli/upload.js +436 -0
- package/dist/cli/upload.js.map +1 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +142 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +475 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/next.d.ts +38 -0
- package/dist/client/next.d.ts.map +1 -0
- package/dist/client/next.js +175 -0
- package/dist/client/next.js.map +1 -0
- package/dist/client/nextAdapter.d.ts +4 -0
- package/dist/client/nextAdapter.d.ts.map +1 -0
- package/dist/client/nextAdapter.js +9 -0
- package/dist/client/nextAdapter.js.map +1 -0
- package/dist/component/_generated/api.d.ts +34 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +73 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/lib.d.ts +88 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +210 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/schema.d.ts +27 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +20 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/react/index.d.ts +80 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +138 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +120 -0
- package/src/cli/deploy.ts +375 -0
- package/src/cli/index.ts +104 -0
- package/src/cli/init.ts +181 -0
- package/src/cli/next-build.ts +707 -0
- package/src/cli/setup.ts +190 -0
- package/src/cli/upload.ts +521 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.test.ts +67 -0
- package/src/client/index.ts +553 -0
- package/src/client/next.ts +223 -0
- package/src/client/nextAdapter.ts +17 -0
- package/src/client/setup.test.ts +26 -0
- package/src/component/_generated/api.ts +50 -0
- package/src/component/_generated/component.ts +104 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +161 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/lib.test.ts +110 -0
- package/src/component/lib.ts +228 -0
- package/src/component/schema.ts +21 -0
- package/src/component/setup.test.ts +11 -0
- package/src/react/index.tsx +184 -0
- package/src/test.ts +18 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { exposeUploadApi, getMimeType } from "./index.js";
|
|
3
|
+
import { anyApi, type ApiFromModules } from "convex/server";
|
|
4
|
+
import { components, initConvexTest } from "./setup.test.js";
|
|
5
|
+
|
|
6
|
+
export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } =
|
|
7
|
+
exposeUploadApi(components.staticHosting);
|
|
8
|
+
|
|
9
|
+
const testApi = (
|
|
10
|
+
anyApi as unknown as ApiFromModules<{
|
|
11
|
+
"index.test": {
|
|
12
|
+
generateUploadUrl: typeof generateUploadUrl;
|
|
13
|
+
recordAsset: typeof recordAsset;
|
|
14
|
+
gcOldAssets: typeof gcOldAssets;
|
|
15
|
+
listAssets: typeof listAssets;
|
|
16
|
+
};
|
|
17
|
+
}>
|
|
18
|
+
)["index.test"];
|
|
19
|
+
|
|
20
|
+
describe("client tests", () => {
|
|
21
|
+
test("should expose upload API functions", async () => {
|
|
22
|
+
const t = initConvexTest();
|
|
23
|
+
|
|
24
|
+
// Test generateUploadUrl
|
|
25
|
+
const uploadUrl = await t.mutation(testApi.generateUploadUrl, {});
|
|
26
|
+
expect(uploadUrl).toBeDefined();
|
|
27
|
+
expect(typeof uploadUrl).toBe("string");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("should list empty assets initially", async () => {
|
|
31
|
+
const t = initConvexTest();
|
|
32
|
+
|
|
33
|
+
const assets = await t.query(testApi.listAssets, {});
|
|
34
|
+
expect(assets).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("gc should return 0 with no assets", async () => {
|
|
38
|
+
const t = initConvexTest();
|
|
39
|
+
|
|
40
|
+
const result = await t.mutation(testApi.gcOldAssets, {
|
|
41
|
+
currentDeploymentId: "test-deployment",
|
|
42
|
+
});
|
|
43
|
+
expect(result.deleted).toBe(0);
|
|
44
|
+
expect(result.blobIds).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("getMimeType", () => {
|
|
49
|
+
test("returns correct MIME types for common extensions", () => {
|
|
50
|
+
expect(getMimeType("/index.html")).toBe("text/html; charset=utf-8");
|
|
51
|
+
expect(getMimeType("/assets/main.js")).toBe(
|
|
52
|
+
"application/javascript; charset=utf-8",
|
|
53
|
+
);
|
|
54
|
+
expect(getMimeType("/styles/app.css")).toBe("text/css; charset=utf-8");
|
|
55
|
+
expect(getMimeType("/data.json")).toBe("application/json; charset=utf-8");
|
|
56
|
+
expect(getMimeType("/image.png")).toBe("image/png");
|
|
57
|
+
expect(getMimeType("/photo.jpg")).toBe("image/jpeg");
|
|
58
|
+
expect(getMimeType("/icon.svg")).toBe("image/svg+xml");
|
|
59
|
+
expect(getMimeType("/favicon.ico")).toBe("image/x-icon");
|
|
60
|
+
expect(getMimeType("/font.woff2")).toBe("font/woff2");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns octet-stream for unknown extensions", () => {
|
|
64
|
+
expect(getMimeType("/file.xyz")).toBe("application/octet-stream");
|
|
65
|
+
expect(getMimeType("/unknown")).toBe("application/octet-stream");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import {
|
|
2
|
+
httpActionGeneric,
|
|
3
|
+
internalMutationGeneric,
|
|
4
|
+
internalQueryGeneric,
|
|
5
|
+
queryGeneric,
|
|
6
|
+
} from "convex/server";
|
|
7
|
+
import type { HttpRouter } from "convex/server";
|
|
8
|
+
import { v } from "convex/values";
|
|
9
|
+
import type { ComponentApi } from "../component/_generated/component.js";
|
|
10
|
+
|
|
11
|
+
// MIME type mapping for common file types
|
|
12
|
+
const MIME_TYPES: Record<string, string> = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "application/javascript; charset=utf-8",
|
|
15
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
16
|
+
".css": "text/css; charset=utf-8",
|
|
17
|
+
".json": "application/json; charset=utf-8",
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".svg": "image/svg+xml",
|
|
23
|
+
".ico": "image/x-icon",
|
|
24
|
+
".webp": "image/webp",
|
|
25
|
+
".woff": "font/woff",
|
|
26
|
+
".woff2": "font/woff2",
|
|
27
|
+
".ttf": "font/ttf",
|
|
28
|
+
".txt": "text/plain; charset=utf-8",
|
|
29
|
+
".map": "application/json",
|
|
30
|
+
".webmanifest": "application/manifest+json",
|
|
31
|
+
".xml": "application/xml",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate HTML page shown when no assets have been deployed yet.
|
|
36
|
+
*/
|
|
37
|
+
function getSetupHtml(): string {
|
|
38
|
+
return `<!DOCTYPE html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="UTF-8">
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
43
|
+
<title>Convex Static Hosting</title>
|
|
44
|
+
<style>
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
body {
|
|
47
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
48
|
+
max-width: 640px;
|
|
49
|
+
margin: 0 auto;
|
|
50
|
+
padding: 40px 20px;
|
|
51
|
+
background: #fafafa;
|
|
52
|
+
color: #333;
|
|
53
|
+
line-height: 1.6;
|
|
54
|
+
}
|
|
55
|
+
h1 { color: #111; margin-bottom: 8px; }
|
|
56
|
+
.subtitle { color: #666; margin-bottom: 32px; }
|
|
57
|
+
code {
|
|
58
|
+
background: #e8e8e8;
|
|
59
|
+
padding: 2px 6px;
|
|
60
|
+
border-radius: 4px;
|
|
61
|
+
font-size: 14px;
|
|
62
|
+
}
|
|
63
|
+
pre {
|
|
64
|
+
background: #1a1a1a;
|
|
65
|
+
color: #f0f0f0;
|
|
66
|
+
padding: 16px;
|
|
67
|
+
border-radius: 8px;
|
|
68
|
+
overflow-x: auto;
|
|
69
|
+
font-size: 14px;
|
|
70
|
+
}
|
|
71
|
+
.step { margin-bottom: 24px; }
|
|
72
|
+
.step-num {
|
|
73
|
+
display: inline-block;
|
|
74
|
+
background: #333;
|
|
75
|
+
color: white;
|
|
76
|
+
width: 24px;
|
|
77
|
+
height: 24px;
|
|
78
|
+
border-radius: 50%;
|
|
79
|
+
text-align: center;
|
|
80
|
+
font-size: 14px;
|
|
81
|
+
line-height: 24px;
|
|
82
|
+
margin-right: 8px;
|
|
83
|
+
}
|
|
84
|
+
a { color: #0070f3; }
|
|
85
|
+
</style>
|
|
86
|
+
</head>
|
|
87
|
+
<body>
|
|
88
|
+
<h1>Almost there!</h1>
|
|
89
|
+
<p class="subtitle">Your Convex backend is running, but no static files have been deployed yet.</p>
|
|
90
|
+
|
|
91
|
+
<div class="step">
|
|
92
|
+
<span class="step-num">1</span>
|
|
93
|
+
<strong>Build your frontend</strong>
|
|
94
|
+
<pre>npm run build</pre>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="step">
|
|
98
|
+
<span class="step-num">2</span>
|
|
99
|
+
<strong>Deploy your static files</strong>
|
|
100
|
+
<pre>npx @convex-dev/static-hosting deploy</pre>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<p>Or deploy everything in one command:</p>
|
|
104
|
+
<pre>npm run deploy</pre>
|
|
105
|
+
|
|
106
|
+
<p style="margin-top: 32px; color: #666; font-size: 14px;">
|
|
107
|
+
Learn more at <a href="https://github.com/get-convex/static-hosting">github.com/get-convex/static-hosting</a>
|
|
108
|
+
</p>
|
|
109
|
+
</body>
|
|
110
|
+
</html>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get MIME type for a file path based on its extension.
|
|
115
|
+
*/
|
|
116
|
+
export function getMimeType(path: string): string {
|
|
117
|
+
const ext = path.substring(path.lastIndexOf(".")).toLowerCase();
|
|
118
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if a path has a file extension.
|
|
123
|
+
*/
|
|
124
|
+
function hasFileExtension(path: string): boolean {
|
|
125
|
+
const lastSegment = path.split("/").pop() || "";
|
|
126
|
+
return lastSegment.includes(".") && !lastSegment.startsWith(".");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if asset is a hashed asset (for cache control).
|
|
131
|
+
* Vite produces: index-lj_vq_aF.js, style-B71cUw87.css
|
|
132
|
+
*/
|
|
133
|
+
function isHashedAsset(path: string): boolean {
|
|
134
|
+
return /[-.][\dA-Za-z_]{6,12}\.[a-z]+$/.test(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Register HTTP routes for serving static files.
|
|
139
|
+
* This creates a catch-all route that serves files from Convex storage
|
|
140
|
+
* with SPA fallback support.
|
|
141
|
+
*
|
|
142
|
+
* @param http - The HTTP router to register routes on
|
|
143
|
+
* @param component - The component API reference
|
|
144
|
+
* @param options - Configuration options
|
|
145
|
+
* @param options.pathPrefix - URL prefix for static files (default: "/")
|
|
146
|
+
* @param options.spaFallback - Enable SPA fallback to index.html (default: true)
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```typescript
|
|
150
|
+
* // In your convex/http.ts
|
|
151
|
+
* import { httpRouter } from "convex/server";
|
|
152
|
+
* import { registerStaticRoutes } from "@convex-dev/static-hosting";
|
|
153
|
+
* import { components } from "./_generated/api";
|
|
154
|
+
*
|
|
155
|
+
* const http = httpRouter();
|
|
156
|
+
*
|
|
157
|
+
* // Serve static files at root
|
|
158
|
+
* registerStaticRoutes(http, components.staticHosting);
|
|
159
|
+
*
|
|
160
|
+
* // Or serve at a specific path prefix
|
|
161
|
+
* registerStaticRoutes(http, components.staticHosting, {
|
|
162
|
+
* pathPrefix: "/app",
|
|
163
|
+
* });
|
|
164
|
+
*
|
|
165
|
+
* export default http;
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Check if a content type is HTML.
|
|
170
|
+
*/
|
|
171
|
+
function isHtmlContentType(contentType: string): boolean {
|
|
172
|
+
return contentType.startsWith("text/html");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function registerStaticRoutes(
|
|
176
|
+
http: HttpRouter,
|
|
177
|
+
component: ComponentApi,
|
|
178
|
+
{
|
|
179
|
+
pathPrefix = "/",
|
|
180
|
+
spaFallback = true,
|
|
181
|
+
cdnBaseUrl,
|
|
182
|
+
}: {
|
|
183
|
+
pathPrefix?: string;
|
|
184
|
+
spaFallback?: boolean;
|
|
185
|
+
/** Base URL for CDN blob redirects (e.g., `(req) => \`${new URL(req.url).origin}/fs/blobs\``).
|
|
186
|
+
* When set, assets with a blobId (non-HTML) will return a 302 redirect to `{cdnBaseUrl}/{blobId}`. */
|
|
187
|
+
cdnBaseUrl?: string | ((request: Request) => string);
|
|
188
|
+
} = {},
|
|
189
|
+
) {
|
|
190
|
+
// Normalize pathPrefix - ensure it starts with / and doesn't end with /
|
|
191
|
+
const normalizedPrefix =
|
|
192
|
+
pathPrefix === "/" ? "" : pathPrefix.replace(/\/$/, "");
|
|
193
|
+
|
|
194
|
+
const serveStaticFile = httpActionGeneric(async (ctx, request) => {
|
|
195
|
+
const url = new URL(request.url);
|
|
196
|
+
let path = url.pathname;
|
|
197
|
+
|
|
198
|
+
// Remove prefix if present
|
|
199
|
+
if (normalizedPrefix && path.startsWith(normalizedPrefix)) {
|
|
200
|
+
path = path.slice(normalizedPrefix.length) || "/";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Normalize: serve index.html for root
|
|
204
|
+
if (path === "" || path === "/") {
|
|
205
|
+
path = "/index.html";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Look up the asset
|
|
209
|
+
type AssetDoc = {
|
|
210
|
+
_id: string;
|
|
211
|
+
_creationTime: number;
|
|
212
|
+
path: string;
|
|
213
|
+
storageId?: string;
|
|
214
|
+
blobId?: string;
|
|
215
|
+
contentType: string;
|
|
216
|
+
deploymentId: string;
|
|
217
|
+
} | null;
|
|
218
|
+
|
|
219
|
+
let asset: AssetDoc = await ctx.runQuery(component.lib.getByPath, { path });
|
|
220
|
+
|
|
221
|
+
// SPA fallback: if not found and no file extension, serve index.html
|
|
222
|
+
if (!asset && spaFallback && !hasFileExtension(path)) {
|
|
223
|
+
asset = await ctx.runQuery(component.lib.getByPath, {
|
|
224
|
+
path: "/index.html",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 404 if still not found
|
|
229
|
+
if (!asset) {
|
|
230
|
+
// If looking for index.html and it's not there, show setup instructions
|
|
231
|
+
if (path === "/index.html") {
|
|
232
|
+
return new Response(getSetupHtml(), {
|
|
233
|
+
status: 200,
|
|
234
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return new Response("Not Found", {
|
|
238
|
+
status: 404,
|
|
239
|
+
headers: { "Content-Type": "text/plain" },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// CDN redirect: if asset has blobId, is not HTML, and cdnBaseUrl is configured
|
|
244
|
+
if (asset.blobId && cdnBaseUrl && !isHtmlContentType(asset.contentType)) {
|
|
245
|
+
const baseUrl =
|
|
246
|
+
typeof cdnBaseUrl === "function" ? cdnBaseUrl(request) : cdnBaseUrl;
|
|
247
|
+
const redirectUrl = `${baseUrl.replace(/\/$/, "")}/${asset.blobId}`;
|
|
248
|
+
|
|
249
|
+
// Cache control for redirect: hashed assets can cache the redirect itself
|
|
250
|
+
const cacheControl = isHashedAsset(path)
|
|
251
|
+
? "public, max-age=31536000, immutable"
|
|
252
|
+
: "public, max-age=0, must-revalidate";
|
|
253
|
+
|
|
254
|
+
return new Response(null, {
|
|
255
|
+
status: 302,
|
|
256
|
+
headers: {
|
|
257
|
+
Location: redirectUrl,
|
|
258
|
+
"Cache-Control": cacheControl,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Serve from Convex storage
|
|
264
|
+
if (!asset.storageId) {
|
|
265
|
+
return new Response("Asset not available", {
|
|
266
|
+
status: 500,
|
|
267
|
+
headers: { "Content-Type": "text/plain" },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Use storageId as ETag (unique per file content)
|
|
272
|
+
const etag = `"${asset.storageId}"`;
|
|
273
|
+
|
|
274
|
+
// Check for conditional request (If-None-Match)
|
|
275
|
+
const ifNoneMatch = request.headers.get("If-None-Match");
|
|
276
|
+
if (ifNoneMatch === etag) {
|
|
277
|
+
// Client has current version - return 304 Not Modified
|
|
278
|
+
return new Response(null, {
|
|
279
|
+
status: 304,
|
|
280
|
+
headers: {
|
|
281
|
+
ETag: etag,
|
|
282
|
+
"Cache-Control": isHashedAsset(path)
|
|
283
|
+
? "public, max-age=31536000, immutable"
|
|
284
|
+
: "public, max-age=0, must-revalidate",
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Get file from storage
|
|
290
|
+
const blob = await ctx.storage.get(asset.storageId);
|
|
291
|
+
if (!blob) {
|
|
292
|
+
return new Response("Storage error", {
|
|
293
|
+
status: 500,
|
|
294
|
+
headers: { "Content-Type": "text/plain" },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Cache control: hashed assets can be cached forever
|
|
299
|
+
const cacheControl = isHashedAsset(path)
|
|
300
|
+
? "public, max-age=31536000, immutable"
|
|
301
|
+
: "public, max-age=0, must-revalidate";
|
|
302
|
+
|
|
303
|
+
return new Response(blob, {
|
|
304
|
+
status: 200,
|
|
305
|
+
headers: {
|
|
306
|
+
"Content-Type": asset.contentType,
|
|
307
|
+
"Cache-Control": cacheControl,
|
|
308
|
+
ETag: etag,
|
|
309
|
+
"X-Content-Type-Options": "nosniff",
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Use pathPrefix routing
|
|
315
|
+
http.route({
|
|
316
|
+
pathPrefix: pathPrefix === "/" ? "/" : `${normalizedPrefix}/`,
|
|
317
|
+
method: "GET",
|
|
318
|
+
handler: serveStaticFile,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Also handle exact prefix without trailing slash
|
|
322
|
+
if (normalizedPrefix) {
|
|
323
|
+
http.route({
|
|
324
|
+
path: normalizedPrefix,
|
|
325
|
+
method: "GET",
|
|
326
|
+
handler: serveStaticFile,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Expose the upload API as INTERNAL functions for secure deployments.
|
|
333
|
+
* These functions can only be called via `npx convex run` or from other Convex functions.
|
|
334
|
+
*
|
|
335
|
+
* @param component - The component API reference
|
|
336
|
+
*
|
|
337
|
+
* @example
|
|
338
|
+
* ```typescript
|
|
339
|
+
* // In your convex/staticHosting.ts
|
|
340
|
+
* import { exposeUploadApi } from "@convex-dev/static-hosting";
|
|
341
|
+
* import { components } from "./_generated/api";
|
|
342
|
+
*
|
|
343
|
+
* export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } =
|
|
344
|
+
* exposeUploadApi(components.staticHosting);
|
|
345
|
+
* ```
|
|
346
|
+
*
|
|
347
|
+
* Then deploy using:
|
|
348
|
+
* ```bash
|
|
349
|
+
* npm run deploy:static
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
export function exposeUploadApi(component: ComponentApi) {
|
|
353
|
+
return {
|
|
354
|
+
/**
|
|
355
|
+
* Generate a signed URL for uploading a file.
|
|
356
|
+
* Files are stored in the app's storage (not the component's).
|
|
357
|
+
*/
|
|
358
|
+
generateUploadUrl: internalMutationGeneric({
|
|
359
|
+
args: {},
|
|
360
|
+
handler: async (ctx) => {
|
|
361
|
+
return await ctx.storage.generateUploadUrl();
|
|
362
|
+
},
|
|
363
|
+
}),
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Record an uploaded asset in the database.
|
|
367
|
+
* Automatically cleans up old storage files when replacing.
|
|
368
|
+
* Pass storageId for Convex storage assets, or blobId for CDN assets.
|
|
369
|
+
*/
|
|
370
|
+
recordAsset: internalMutationGeneric({
|
|
371
|
+
args: {
|
|
372
|
+
path: v.string(),
|
|
373
|
+
storageId: v.optional(v.string()),
|
|
374
|
+
blobId: v.optional(v.string()),
|
|
375
|
+
contentType: v.string(),
|
|
376
|
+
deploymentId: v.string(),
|
|
377
|
+
},
|
|
378
|
+
handler: async (ctx, args) => {
|
|
379
|
+
const { oldStorageId, oldBlobId } = await ctx.runMutation(
|
|
380
|
+
component.lib.recordAsset,
|
|
381
|
+
{
|
|
382
|
+
path: args.path,
|
|
383
|
+
...(args.storageId ? { storageId: args.storageId } : {}),
|
|
384
|
+
...(args.blobId ? { blobId: args.blobId } : {}),
|
|
385
|
+
contentType: args.contentType,
|
|
386
|
+
deploymentId: args.deploymentId,
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
if (oldStorageId) {
|
|
390
|
+
try {
|
|
391
|
+
await ctx.storage.delete(oldStorageId);
|
|
392
|
+
} catch {
|
|
393
|
+
// Ignore - old file may have been in different storage
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Return oldBlobId so caller can clean up CDN blobs if needed
|
|
397
|
+
return oldBlobId ?? null;
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Garbage collect old assets and notify clients of the new deployment.
|
|
403
|
+
* Returns the count of deleted assets.
|
|
404
|
+
* Also triggers connected clients to reload via the deployment subscription.
|
|
405
|
+
*/
|
|
406
|
+
gcOldAssets: internalMutationGeneric({
|
|
407
|
+
args: {
|
|
408
|
+
currentDeploymentId: v.string(),
|
|
409
|
+
},
|
|
410
|
+
handler: async (ctx, args) => {
|
|
411
|
+
const { storageIds, blobIds } = await ctx.runMutation(
|
|
412
|
+
component.lib.gcOldAssets,
|
|
413
|
+
{
|
|
414
|
+
currentDeploymentId: args.currentDeploymentId,
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
for (const storageId of storageIds) {
|
|
418
|
+
try {
|
|
419
|
+
await ctx.storage.delete(storageId);
|
|
420
|
+
} catch {
|
|
421
|
+
// Ignore - old file may have been in different storage
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Update deployment info to trigger client reloads
|
|
426
|
+
await ctx.runMutation(component.lib.setCurrentDeployment, {
|
|
427
|
+
deploymentId: args.currentDeploymentId,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Return both counts and blobIds for CDN cleanup
|
|
431
|
+
return { deleted: storageIds.length, blobIds };
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Generate multiple signed upload URLs in one call.
|
|
437
|
+
* Much faster than calling generateUploadUrl N times.
|
|
438
|
+
*/
|
|
439
|
+
generateUploadUrls: internalMutationGeneric({
|
|
440
|
+
args: { count: v.number() },
|
|
441
|
+
handler: async (ctx, { count }) => {
|
|
442
|
+
const urls: string[] = [];
|
|
443
|
+
for (let i = 0; i < count; i++) {
|
|
444
|
+
urls.push(await ctx.storage.generateUploadUrl());
|
|
445
|
+
}
|
|
446
|
+
return urls;
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Record multiple uploaded assets in one call.
|
|
452
|
+
*/
|
|
453
|
+
recordAssets: internalMutationGeneric({
|
|
454
|
+
args: {
|
|
455
|
+
assets: v.array(
|
|
456
|
+
v.object({
|
|
457
|
+
path: v.string(),
|
|
458
|
+
storageId: v.string(),
|
|
459
|
+
contentType: v.string(),
|
|
460
|
+
deploymentId: v.string(),
|
|
461
|
+
}),
|
|
462
|
+
),
|
|
463
|
+
},
|
|
464
|
+
handler: async (ctx, { assets }) => {
|
|
465
|
+
for (const asset of assets) {
|
|
466
|
+
await ctx.runMutation(component.lib.recordAsset, {
|
|
467
|
+
path: asset.path,
|
|
468
|
+
storageId: asset.storageId,
|
|
469
|
+
contentType: asset.contentType,
|
|
470
|
+
deploymentId: asset.deploymentId,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* List all static assets (for debugging).
|
|
478
|
+
*/
|
|
479
|
+
listAssets: internalQueryGeneric({
|
|
480
|
+
args: {
|
|
481
|
+
limit: v.optional(v.number()),
|
|
482
|
+
},
|
|
483
|
+
handler: async (ctx, args) => {
|
|
484
|
+
return await ctx.runQuery(component.lib.listAssets, {
|
|
485
|
+
limit: args.limit,
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
}),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Expose a query that clients can subscribe to for live reload on deploy.
|
|
494
|
+
* When a new deployment happens, subscribed clients will be notified.
|
|
495
|
+
*
|
|
496
|
+
* @param component - The component API reference
|
|
497
|
+
*
|
|
498
|
+
* @example
|
|
499
|
+
* ```typescript
|
|
500
|
+
* // In your convex/staticHosting.ts
|
|
501
|
+
* import { exposeUploadApi, exposeDeploymentQuery } from "@convex-dev/static-hosting";
|
|
502
|
+
* import { components } from "./_generated/api";
|
|
503
|
+
*
|
|
504
|
+
* export const { generateUploadUrl, recordAsset, gcOldAssets, listAssets } =
|
|
505
|
+
* exposeUploadApi(components.staticHosting);
|
|
506
|
+
*
|
|
507
|
+
* export const { getCurrentDeployment } = exposeDeploymentQuery(components.staticHosting);
|
|
508
|
+
* ```
|
|
509
|
+
*/
|
|
510
|
+
export function exposeDeploymentQuery(component: ComponentApi) {
|
|
511
|
+
return {
|
|
512
|
+
/**
|
|
513
|
+
* Get the current deployment info.
|
|
514
|
+
* Subscribe to this query to detect when a new deployment happens.
|
|
515
|
+
*/
|
|
516
|
+
getCurrentDeployment: queryGeneric({
|
|
517
|
+
args: {},
|
|
518
|
+
handler: async (ctx) => {
|
|
519
|
+
return await ctx.runQuery(component.lib.getCurrentDeployment, {});
|
|
520
|
+
},
|
|
521
|
+
}),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Derive the Convex cloud URL from a .convex.site hostname.
|
|
527
|
+
* Useful for client-side code that needs to connect to the Convex backend
|
|
528
|
+
* when hosted on Convex static hosting.
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* ```typescript
|
|
532
|
+
* // In your React app's main.tsx
|
|
533
|
+
* import { getConvexUrl } from "@convex-dev/static-hosting";
|
|
534
|
+
*
|
|
535
|
+
* const convexUrl = import.meta.env.VITE_CONVEX_URL ?? getConvexUrl();
|
|
536
|
+
* const convex = new ConvexReactClient(convexUrl);
|
|
537
|
+
* ```
|
|
538
|
+
*/
|
|
539
|
+
export function getConvexUrl(): string {
|
|
540
|
+
if (typeof window === "undefined") {
|
|
541
|
+
throw new Error("getConvexUrl() can only be called in a browser context");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// If hosted on Convex (.convex.site), derive API URL (.convex.cloud)
|
|
545
|
+
if (window.location.hostname.endsWith(".convex.site")) {
|
|
546
|
+
return `https://${window.location.hostname.replace(".convex.site", ".convex.cloud")}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
throw new Error(
|
|
550
|
+
"Unable to derive Convex URL. Please set VITE_CONVEX_URL environment variable.",
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|