@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.
Files changed (98) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +333 -0
  3. package/dist/cli/deploy.d.ts +16 -0
  4. package/dist/cli/deploy.d.ts.map +1 -0
  5. package/dist/cli/deploy.js +324 -0
  6. package/dist/cli/deploy.js.map +1 -0
  7. package/dist/cli/index.d.ts +15 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +95 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/cli/init.d.ts +9 -0
  12. package/dist/cli/init.d.ts.map +1 -0
  13. package/dist/cli/init.js +181 -0
  14. package/dist/cli/init.js.map +1 -0
  15. package/dist/cli/next-build.d.ts +24 -0
  16. package/dist/cli/next-build.d.ts.map +1 -0
  17. package/dist/cli/next-build.js +569 -0
  18. package/dist/cli/next-build.js.map +1 -0
  19. package/dist/cli/setup.d.ts +9 -0
  20. package/dist/cli/setup.d.ts.map +1 -0
  21. package/dist/cli/setup.js +157 -0
  22. package/dist/cli/setup.js.map +1 -0
  23. package/dist/cli/upload.d.ts +15 -0
  24. package/dist/cli/upload.d.ts.map +1 -0
  25. package/dist/cli/upload.js +436 -0
  26. package/dist/cli/upload.js.map +1 -0
  27. package/dist/client/_generated/_ignore.d.ts +1 -0
  28. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  29. package/dist/client/_generated/_ignore.js +3 -0
  30. package/dist/client/_generated/_ignore.js.map +1 -0
  31. package/dist/client/index.d.ts +142 -0
  32. package/dist/client/index.d.ts.map +1 -0
  33. package/dist/client/index.js +475 -0
  34. package/dist/client/index.js.map +1 -0
  35. package/dist/client/next.d.ts +38 -0
  36. package/dist/client/next.d.ts.map +1 -0
  37. package/dist/client/next.js +175 -0
  38. package/dist/client/next.js.map +1 -0
  39. package/dist/client/nextAdapter.d.ts +4 -0
  40. package/dist/client/nextAdapter.d.ts.map +1 -0
  41. package/dist/client/nextAdapter.js +9 -0
  42. package/dist/client/nextAdapter.js.map +1 -0
  43. package/dist/component/_generated/api.d.ts +34 -0
  44. package/dist/component/_generated/api.d.ts.map +1 -0
  45. package/dist/component/_generated/api.js +31 -0
  46. package/dist/component/_generated/api.js.map +1 -0
  47. package/dist/component/_generated/component.d.ts +73 -0
  48. package/dist/component/_generated/component.d.ts.map +1 -0
  49. package/dist/component/_generated/component.js +11 -0
  50. package/dist/component/_generated/component.js.map +1 -0
  51. package/dist/component/_generated/dataModel.d.ts +46 -0
  52. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  53. package/dist/component/_generated/dataModel.js +11 -0
  54. package/dist/component/_generated/dataModel.js.map +1 -0
  55. package/dist/component/_generated/server.d.ts +121 -0
  56. package/dist/component/_generated/server.d.ts.map +1 -0
  57. package/dist/component/_generated/server.js +78 -0
  58. package/dist/component/_generated/server.js.map +1 -0
  59. package/dist/component/convex.config.d.ts +3 -0
  60. package/dist/component/convex.config.d.ts.map +1 -0
  61. package/dist/component/convex.config.js +3 -0
  62. package/dist/component/convex.config.js.map +1 -0
  63. package/dist/component/lib.d.ts +88 -0
  64. package/dist/component/lib.d.ts.map +1 -0
  65. package/dist/component/lib.js +210 -0
  66. package/dist/component/lib.js.map +1 -0
  67. package/dist/component/schema.d.ts +27 -0
  68. package/dist/component/schema.d.ts.map +1 -0
  69. package/dist/component/schema.js +20 -0
  70. package/dist/component/schema.js.map +1 -0
  71. package/dist/react/index.d.ts +80 -0
  72. package/dist/react/index.d.ts.map +1 -0
  73. package/dist/react/index.js +138 -0
  74. package/dist/react/index.js.map +1 -0
  75. package/package.json +120 -0
  76. package/src/cli/deploy.ts +375 -0
  77. package/src/cli/index.ts +104 -0
  78. package/src/cli/init.ts +181 -0
  79. package/src/cli/next-build.ts +707 -0
  80. package/src/cli/setup.ts +190 -0
  81. package/src/cli/upload.ts +521 -0
  82. package/src/client/_generated/_ignore.ts +1 -0
  83. package/src/client/index.test.ts +67 -0
  84. package/src/client/index.ts +553 -0
  85. package/src/client/next.ts +223 -0
  86. package/src/client/nextAdapter.ts +17 -0
  87. package/src/client/setup.test.ts +26 -0
  88. package/src/component/_generated/api.ts +50 -0
  89. package/src/component/_generated/component.ts +104 -0
  90. package/src/component/_generated/dataModel.ts +60 -0
  91. package/src/component/_generated/server.ts +161 -0
  92. package/src/component/convex.config.ts +3 -0
  93. package/src/component/lib.test.ts +110 -0
  94. package/src/component/lib.ts +228 -0
  95. package/src/component/schema.ts +21 -0
  96. package/src/component/setup.test.ts +11 -0
  97. package/src/react/index.tsx +184 -0
  98. 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 };