@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,110 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { api } from "./_generated/api.js";
|
|
5
|
+
import { initConvexTest } from "./setup.test.js";
|
|
6
|
+
|
|
7
|
+
describe("component lib", () => {
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("can record and retrieve assets", async () => {
|
|
16
|
+
const t = initConvexTest();
|
|
17
|
+
|
|
18
|
+
// First upload a file to storage (mock with a fake storageId)
|
|
19
|
+
const uploadUrl = await t.mutation(api.lib.generateUploadUrl, {});
|
|
20
|
+
expect(uploadUrl).toBeDefined();
|
|
21
|
+
expect(typeof uploadUrl).toBe("string");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("can look up assets by path", async () => {
|
|
25
|
+
const t = initConvexTest();
|
|
26
|
+
|
|
27
|
+
// Look up a non-existent path
|
|
28
|
+
const asset = await t.query(api.lib.getByPath, { path: "/index.html" });
|
|
29
|
+
expect(asset).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("can list assets", async () => {
|
|
33
|
+
const t = initConvexTest();
|
|
34
|
+
|
|
35
|
+
const assets = await t.query(api.lib.listAssets, {});
|
|
36
|
+
expect(assets).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("gc removes old assets", async () => {
|
|
40
|
+
const t = initConvexTest();
|
|
41
|
+
|
|
42
|
+
// GC with no assets should return empty arrays
|
|
43
|
+
const result = await t.mutation(api.lib.gcOldAssets, {
|
|
44
|
+
currentDeploymentId: "test-deployment",
|
|
45
|
+
});
|
|
46
|
+
expect(result.storageIds).toHaveLength(0);
|
|
47
|
+
expect(result.blobIds).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("recordAsset returns old IDs when replacing", async () => {
|
|
51
|
+
const t = initConvexTest();
|
|
52
|
+
|
|
53
|
+
// Record a new asset (no previous) - should return nulls
|
|
54
|
+
const first = await t.mutation(api.lib.recordAsset, {
|
|
55
|
+
path: "/test.js",
|
|
56
|
+
blobId: "blob-123",
|
|
57
|
+
contentType: "application/javascript; charset=utf-8",
|
|
58
|
+
deploymentId: "deploy-1",
|
|
59
|
+
});
|
|
60
|
+
expect(first.oldStorageId).toBeNull();
|
|
61
|
+
expect(first.oldBlobId).toBeNull();
|
|
62
|
+
|
|
63
|
+
// Replace with a new blobId - should return the old one
|
|
64
|
+
const second = await t.mutation(api.lib.recordAsset, {
|
|
65
|
+
path: "/test.js",
|
|
66
|
+
blobId: "blob-456",
|
|
67
|
+
contentType: "application/javascript; charset=utf-8",
|
|
68
|
+
deploymentId: "deploy-2",
|
|
69
|
+
});
|
|
70
|
+
expect(second.oldStorageId).toBeNull();
|
|
71
|
+
expect(second.oldBlobId).toBe("blob-123");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("gc returns blobIds for CDN assets", async () => {
|
|
75
|
+
const t = initConvexTest();
|
|
76
|
+
|
|
77
|
+
// Record a CDN asset
|
|
78
|
+
await t.mutation(api.lib.recordAsset, {
|
|
79
|
+
path: "/assets/main.js",
|
|
80
|
+
blobId: "blob-abc",
|
|
81
|
+
contentType: "application/javascript; charset=utf-8",
|
|
82
|
+
deploymentId: "deploy-old",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// GC should return the blobId
|
|
86
|
+
const result = await t.mutation(api.lib.gcOldAssets, {
|
|
87
|
+
currentDeploymentId: "deploy-new",
|
|
88
|
+
});
|
|
89
|
+
expect(result.storageIds).toHaveLength(0);
|
|
90
|
+
expect(result.blobIds).toEqual(["blob-abc"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("asset with blobId can be looked up by path", async () => {
|
|
94
|
+
const t = initConvexTest();
|
|
95
|
+
|
|
96
|
+
await t.mutation(api.lib.recordAsset, {
|
|
97
|
+
path: "/assets/style.css",
|
|
98
|
+
blobId: "blob-xyz",
|
|
99
|
+
contentType: "text/css; charset=utf-8",
|
|
100
|
+
deploymentId: "deploy-1",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const asset = await t.query(api.lib.getByPath, {
|
|
104
|
+
path: "/assets/style.css",
|
|
105
|
+
});
|
|
106
|
+
expect(asset).not.toBeNull();
|
|
107
|
+
expect(asset!.blobId).toBe("blob-xyz");
|
|
108
|
+
expect(asset!.storageId).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query, internalMutation } from "./_generated/server.js";
|
|
3
|
+
|
|
4
|
+
// Validator for static asset documents (including system fields)
|
|
5
|
+
const staticAssetValidator = v.object({
|
|
6
|
+
_id: v.id("staticAssets"),
|
|
7
|
+
_creationTime: v.number(),
|
|
8
|
+
path: v.string(),
|
|
9
|
+
storageId: v.optional(v.id("_storage")),
|
|
10
|
+
blobId: v.optional(v.string()),
|
|
11
|
+
contentType: v.string(),
|
|
12
|
+
deploymentId: v.string(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Look up an asset by its URL path.
|
|
17
|
+
*/
|
|
18
|
+
export const getByPath = query({
|
|
19
|
+
args: { path: v.string() },
|
|
20
|
+
returns: v.union(staticAssetValidator, v.null()),
|
|
21
|
+
handler: async (ctx, args) => {
|
|
22
|
+
return await ctx.db
|
|
23
|
+
.query("staticAssets")
|
|
24
|
+
.withIndex("by_path", (q) => q.eq("path", args.path))
|
|
25
|
+
.unique();
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate a signed URL for uploading a file to Convex storage.
|
|
31
|
+
* Note: This is kept for backwards compatibility but the recommended approach
|
|
32
|
+
* is to use the app's storage directly via exposeUploadApi().
|
|
33
|
+
*/
|
|
34
|
+
export const generateUploadUrl = mutation({
|
|
35
|
+
args: {},
|
|
36
|
+
returns: v.string(),
|
|
37
|
+
handler: async (ctx) => {
|
|
38
|
+
return await ctx.storage.generateUploadUrl();
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Record an asset in the database after uploading to storage.
|
|
44
|
+
* If an asset already exists at this path, returns the old storageId for cleanup.
|
|
45
|
+
*
|
|
46
|
+
* Note: Storage files are stored in the app's storage, not the component's storage.
|
|
47
|
+
* The caller is responsible for deleting the returned storageId from app storage.
|
|
48
|
+
*/
|
|
49
|
+
export const recordAsset = mutation({
|
|
50
|
+
args: {
|
|
51
|
+
path: v.string(),
|
|
52
|
+
storageId: v.optional(v.id("_storage")),
|
|
53
|
+
blobId: v.optional(v.string()),
|
|
54
|
+
contentType: v.string(),
|
|
55
|
+
deploymentId: v.string(),
|
|
56
|
+
},
|
|
57
|
+
returns: v.object({
|
|
58
|
+
oldStorageId: v.union(v.id("_storage"), v.null()),
|
|
59
|
+
oldBlobId: v.union(v.string(), v.null()),
|
|
60
|
+
}),
|
|
61
|
+
handler: async (ctx, args) => {
|
|
62
|
+
// Check if asset already exists at this path
|
|
63
|
+
const existing = await ctx.db
|
|
64
|
+
.query("staticAssets")
|
|
65
|
+
.withIndex("by_path", (q) => q.eq("path", args.path))
|
|
66
|
+
.unique();
|
|
67
|
+
|
|
68
|
+
let oldStorageId = null;
|
|
69
|
+
let oldBlobId = null;
|
|
70
|
+
if (existing) {
|
|
71
|
+
oldStorageId = existing.storageId ?? null;
|
|
72
|
+
oldBlobId = existing.blobId ?? null;
|
|
73
|
+
// Delete old record
|
|
74
|
+
await ctx.db.delete(existing._id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Insert new asset
|
|
78
|
+
await ctx.db.insert("staticAssets", {
|
|
79
|
+
path: args.path,
|
|
80
|
+
...(args.storageId ? { storageId: args.storageId } : {}),
|
|
81
|
+
...(args.blobId ? { blobId: args.blobId } : {}),
|
|
82
|
+
contentType: args.contentType,
|
|
83
|
+
deploymentId: args.deploymentId,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Return old IDs so caller can clean up
|
|
87
|
+
return { oldStorageId, oldBlobId };
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Garbage collect assets from old deployments.
|
|
93
|
+
* Returns the storageIds that need to be deleted from app storage.
|
|
94
|
+
*/
|
|
95
|
+
export const gcOldAssets = mutation({
|
|
96
|
+
args: {
|
|
97
|
+
currentDeploymentId: v.string(),
|
|
98
|
+
},
|
|
99
|
+
returns: v.object({
|
|
100
|
+
storageIds: v.array(v.id("_storage")),
|
|
101
|
+
blobIds: v.array(v.string()),
|
|
102
|
+
}),
|
|
103
|
+
handler: async (ctx, args) => {
|
|
104
|
+
const oldAssets = await ctx.db.query("staticAssets").collect();
|
|
105
|
+
const storageIds: Array<string> = [];
|
|
106
|
+
const blobIds: Array<string> = [];
|
|
107
|
+
|
|
108
|
+
for (const asset of oldAssets) {
|
|
109
|
+
if (asset.deploymentId !== args.currentDeploymentId) {
|
|
110
|
+
if (asset.storageId) {
|
|
111
|
+
storageIds.push(asset.storageId as unknown as string);
|
|
112
|
+
}
|
|
113
|
+
if (asset.blobId) {
|
|
114
|
+
blobIds.push(asset.blobId);
|
|
115
|
+
}
|
|
116
|
+
// Delete database record
|
|
117
|
+
await ctx.db.delete(asset._id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
storageIds: storageIds as unknown as Array<ReturnType<typeof v.id<"_storage">>["type"]>,
|
|
123
|
+
blobIds,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* List all assets (useful for debugging).
|
|
130
|
+
*/
|
|
131
|
+
export const listAssets = query({
|
|
132
|
+
args: {
|
|
133
|
+
limit: v.optional(v.number()),
|
|
134
|
+
},
|
|
135
|
+
returns: v.array(staticAssetValidator),
|
|
136
|
+
handler: async (ctx, args) => {
|
|
137
|
+
return await ctx.db
|
|
138
|
+
.query("staticAssets")
|
|
139
|
+
.order("asc")
|
|
140
|
+
.take(args.limit ?? 100);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Delete all assets records (useful for cleanup).
|
|
146
|
+
* Returns storageIds that need to be deleted from app storage.
|
|
147
|
+
*/
|
|
148
|
+
export const deleteAllAssets = internalMutation({
|
|
149
|
+
args: {},
|
|
150
|
+
returns: v.object({
|
|
151
|
+
storageIds: v.array(v.id("_storage")),
|
|
152
|
+
blobIds: v.array(v.string()),
|
|
153
|
+
}),
|
|
154
|
+
handler: async (ctx) => {
|
|
155
|
+
const assets = await ctx.db.query("staticAssets").collect();
|
|
156
|
+
const storageIds: Array<string> = [];
|
|
157
|
+
const blobIds: Array<string> = [];
|
|
158
|
+
|
|
159
|
+
for (const asset of assets) {
|
|
160
|
+
if (asset.storageId) {
|
|
161
|
+
storageIds.push(asset.storageId as unknown as string);
|
|
162
|
+
}
|
|
163
|
+
if (asset.blobId) {
|
|
164
|
+
blobIds.push(asset.blobId);
|
|
165
|
+
}
|
|
166
|
+
await ctx.db.delete(asset._id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
storageIds: storageIds as unknown as Array<ReturnType<typeof v.id<"_storage">>["type"]>,
|
|
171
|
+
blobIds,
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// Deployment Tracking - for live reload on deploy
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
const deploymentInfoValidator = v.object({
|
|
181
|
+
_id: v.id("deploymentInfo"),
|
|
182
|
+
_creationTime: v.number(),
|
|
183
|
+
currentDeploymentId: v.string(),
|
|
184
|
+
deployedAt: v.number(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get the current deployment info.
|
|
189
|
+
* Clients subscribe to this to detect when a new deployment happens.
|
|
190
|
+
*/
|
|
191
|
+
export const getCurrentDeployment = query({
|
|
192
|
+
args: {},
|
|
193
|
+
returns: v.union(deploymentInfoValidator, v.null()),
|
|
194
|
+
handler: async (ctx) => {
|
|
195
|
+
return await ctx.db.query("deploymentInfo").first();
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Update the current deployment ID.
|
|
201
|
+
* Called after a successful deployment to notify all connected clients.
|
|
202
|
+
*/
|
|
203
|
+
export const setCurrentDeployment = mutation({
|
|
204
|
+
args: {
|
|
205
|
+
deploymentId: v.string(),
|
|
206
|
+
},
|
|
207
|
+
returns: v.null(),
|
|
208
|
+
handler: async (ctx, args) => {
|
|
209
|
+
// Get existing deployment info
|
|
210
|
+
const existing = await ctx.db.query("deploymentInfo").first();
|
|
211
|
+
|
|
212
|
+
if (existing) {
|
|
213
|
+
// Update existing record
|
|
214
|
+
await ctx.db.patch(existing._id, {
|
|
215
|
+
currentDeploymentId: args.deploymentId,
|
|
216
|
+
deployedAt: Date.now(),
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
// Create new record
|
|
220
|
+
await ctx.db.insert("deploymentInfo", {
|
|
221
|
+
currentDeploymentId: args.deploymentId,
|
|
222
|
+
deployedAt: Date.now(),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return null;
|
|
227
|
+
},
|
|
228
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
staticAssets: defineTable({
|
|
6
|
+
path: v.string(), // URL path, e.g., "/index.html", "/assets/main-abc123.js"
|
|
7
|
+
storageId: v.optional(v.id("_storage")), // Reference to Convex file storage (used for HTML + non-CDN assets)
|
|
8
|
+
blobId: v.optional(v.string()), // convex-fs blob ID (used for CDN-served assets)
|
|
9
|
+
contentType: v.string(), // MIME type, e.g., "text/html; charset=utf-8"
|
|
10
|
+
deploymentId: v.string(), // UUID for garbage collection
|
|
11
|
+
})
|
|
12
|
+
.index("by_path", ["path"])
|
|
13
|
+
.index("by_deploymentId", ["deploymentId"]),
|
|
14
|
+
|
|
15
|
+
// Singleton table to track the current deployment
|
|
16
|
+
// Clients subscribe to this to know when to reload
|
|
17
|
+
deploymentInfo: defineTable({
|
|
18
|
+
currentDeploymentId: v.string(),
|
|
19
|
+
deployedAt: v.number(), // timestamp
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { test } from "vitest";
|
|
3
|
+
import schema from "./schema.js";
|
|
4
|
+
import { convexTest } from "convex-test";
|
|
5
|
+
export const modules = import.meta.glob("./**/*.*s");
|
|
6
|
+
|
|
7
|
+
export function initConvexTest() {
|
|
8
|
+
const t = convexTest(schema, modules);
|
|
9
|
+
return t;
|
|
10
|
+
}
|
|
11
|
+
test("setup", () => {});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "convex/react";
|
|
4
|
+
import { useState, useMemo, type CSSProperties, type JSX } from "react";
|
|
5
|
+
import type { FunctionReference } from "convex/server";
|
|
6
|
+
|
|
7
|
+
type DeploymentInfo = {
|
|
8
|
+
_id: string;
|
|
9
|
+
_creationTime: number;
|
|
10
|
+
currentDeploymentId: string;
|
|
11
|
+
deployedAt: number;
|
|
12
|
+
} | null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hook to detect when a new deployment is available.
|
|
16
|
+
* Shows a prompt to the user instead of auto-reloading.
|
|
17
|
+
*
|
|
18
|
+
* @param getCurrentDeployment - The query function reference from exposeDeploymentQuery
|
|
19
|
+
* @returns Object with update status and reload function
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* import { useDeploymentUpdates } from "@convex-dev/static-hosting/react";
|
|
24
|
+
* import { api } from "../convex/_generated/api";
|
|
25
|
+
*
|
|
26
|
+
* function App() {
|
|
27
|
+
* const { updateAvailable, reload } = useDeploymentUpdates(
|
|
28
|
+
* api.staticHosting.getCurrentDeployment
|
|
29
|
+
* );
|
|
30
|
+
*
|
|
31
|
+
* return (
|
|
32
|
+
* <div>
|
|
33
|
+
* {updateAvailable && (
|
|
34
|
+
* <div className="update-banner">
|
|
35
|
+
* A new version is available!
|
|
36
|
+
* <button onClick={reload}>Reload</button>
|
|
37
|
+
* </div>
|
|
38
|
+
* )}
|
|
39
|
+
* {/* rest of your app *\/}
|
|
40
|
+
* </div>
|
|
41
|
+
* );
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function useDeploymentUpdates(
|
|
46
|
+
getCurrentDeployment: FunctionReference<"query", "public", Record<string, never>, DeploymentInfo>,
|
|
47
|
+
) {
|
|
48
|
+
const deployment = useQuery(getCurrentDeployment, {});
|
|
49
|
+
const [initialDeploymentId, setInitialDeploymentId] = useState<string | null>(null);
|
|
50
|
+
const [dismissedDeploymentId, setDismissedDeploymentId] = useState<string | null>(null);
|
|
51
|
+
|
|
52
|
+
// Capture the initial deployment ID on first load
|
|
53
|
+
// Using useState with functional update to avoid stale closure issues
|
|
54
|
+
if (deployment && initialDeploymentId === null) {
|
|
55
|
+
// This is safe - we're setting initial state based on first data load
|
|
56
|
+
// It only runs once when deployment first becomes available
|
|
57
|
+
setInitialDeploymentId(deployment.currentDeploymentId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Derive updateAvailable from current state
|
|
61
|
+
const updateAvailable = useMemo(() => {
|
|
62
|
+
if (!deployment || initialDeploymentId === null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// Show update if deployment changed from initial AND user hasn't dismissed this one
|
|
66
|
+
const hasNewDeployment = deployment.currentDeploymentId !== initialDeploymentId;
|
|
67
|
+
const isDismissed = deployment.currentDeploymentId === dismissedDeploymentId;
|
|
68
|
+
return hasNewDeployment && !isDismissed;
|
|
69
|
+
}, [deployment, initialDeploymentId, dismissedDeploymentId]);
|
|
70
|
+
|
|
71
|
+
const reload = () => {
|
|
72
|
+
window.location.reload();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const dismiss = () => {
|
|
76
|
+
if (deployment) {
|
|
77
|
+
setDismissedDeploymentId(deployment.currentDeploymentId);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
/** True when a new deployment is available */
|
|
83
|
+
updateAvailable,
|
|
84
|
+
/** Reload the page to get the new version */
|
|
85
|
+
reload,
|
|
86
|
+
/** Dismiss the update notification (until next deploy) */
|
|
87
|
+
dismiss,
|
|
88
|
+
/** The current deployment info (or null if not yet loaded) */
|
|
89
|
+
deployment,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A ready-to-use update banner component.
|
|
95
|
+
* Displays a notification when a new deployment is available.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```tsx
|
|
99
|
+
* import { UpdateBanner } from "@convex-dev/static-hosting/react";
|
|
100
|
+
* import { api } from "../convex/_generated/api";
|
|
101
|
+
*
|
|
102
|
+
* function App() {
|
|
103
|
+
* return (
|
|
104
|
+
* <div>
|
|
105
|
+
* <UpdateBanner
|
|
106
|
+
* getCurrentDeployment={api.staticHosting.getCurrentDeployment}
|
|
107
|
+
* />
|
|
108
|
+
* {/* rest of your app *\/}
|
|
109
|
+
* </div>
|
|
110
|
+
* );
|
|
111
|
+
* }
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export function UpdateBanner({
|
|
115
|
+
getCurrentDeployment,
|
|
116
|
+
message = "A new version is available!",
|
|
117
|
+
buttonText = "Reload",
|
|
118
|
+
dismissable = true,
|
|
119
|
+
className,
|
|
120
|
+
style,
|
|
121
|
+
}: {
|
|
122
|
+
getCurrentDeployment: FunctionReference<"query", "public", Record<string, never>, DeploymentInfo>;
|
|
123
|
+
message?: string;
|
|
124
|
+
buttonText?: string;
|
|
125
|
+
dismissable?: boolean;
|
|
126
|
+
className?: string;
|
|
127
|
+
style?: CSSProperties;
|
|
128
|
+
}): JSX.Element | null {
|
|
129
|
+
const { updateAvailable, reload, dismiss } = useDeploymentUpdates(getCurrentDeployment);
|
|
130
|
+
|
|
131
|
+
if (!updateAvailable) return null;
|
|
132
|
+
|
|
133
|
+
const defaultStyle: CSSProperties = {
|
|
134
|
+
position: "fixed",
|
|
135
|
+
bottom: "1rem",
|
|
136
|
+
right: "1rem",
|
|
137
|
+
backgroundColor: "#1a1a2e",
|
|
138
|
+
color: "#fff",
|
|
139
|
+
padding: "1rem 1.5rem",
|
|
140
|
+
borderRadius: "8px",
|
|
141
|
+
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
|
142
|
+
display: "flex",
|
|
143
|
+
alignItems: "center",
|
|
144
|
+
gap: "1rem",
|
|
145
|
+
zIndex: 9999,
|
|
146
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
147
|
+
fontSize: "14px",
|
|
148
|
+
...style,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const buttonStyle: CSSProperties = {
|
|
152
|
+
backgroundColor: "#4f46e5",
|
|
153
|
+
color: "#fff",
|
|
154
|
+
border: "none",
|
|
155
|
+
padding: "0.5rem 1rem",
|
|
156
|
+
borderRadius: "4px",
|
|
157
|
+
cursor: "pointer",
|
|
158
|
+
fontWeight: 500,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const dismissStyle: CSSProperties = {
|
|
162
|
+
background: "none",
|
|
163
|
+
border: "none",
|
|
164
|
+
color: "#888",
|
|
165
|
+
cursor: "pointer",
|
|
166
|
+
padding: "0.25rem",
|
|
167
|
+
fontSize: "18px",
|
|
168
|
+
lineHeight: 1,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className={className} style={defaultStyle}>
|
|
173
|
+
<span>{message}</span>
|
|
174
|
+
<button onClick={reload} style={buttonStyle}>
|
|
175
|
+
{buttonText}
|
|
176
|
+
</button>
|
|
177
|
+
{dismissable && (
|
|
178
|
+
<button onClick={dismiss} style={dismissStyle} aria-label="Dismiss">
|
|
179
|
+
×
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import type { TestConvex } from "convex-test";
|
|
3
|
+
import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
4
|
+
import schema from "./component/schema.js";
|
|
5
|
+
const modules = import.meta.glob("./component/**/*.ts");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the component with the test convex instance.
|
|
9
|
+
* @param t - The test convex instance, e.g. from calling `convexTest`.
|
|
10
|
+
* @param name - The name of the component, as registered in convex.config.ts.
|
|
11
|
+
*/
|
|
12
|
+
export function register(
|
|
13
|
+
t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
|
|
14
|
+
name: string = "staticHosting",
|
|
15
|
+
) {
|
|
16
|
+
t.registerComponent(name, schema, modules);
|
|
17
|
+
}
|
|
18
|
+
export default { register, schema, modules };
|