@samebase/convex-hosting 0.0.0-stage → 0.3.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 (116) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/INTEGRATION.md +399 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +7 -0
  5. package/README.md +470 -2
  6. package/dist/cli/args.d.ts +28 -0
  7. package/dist/cli/args.d.ts.map +1 -0
  8. package/dist/cli/args.js +148 -0
  9. package/dist/cli/args.js.map +1 -0
  10. package/dist/cli/argumentChunks.d.ts +3 -0
  11. package/dist/cli/argumentChunks.d.ts.map +1 -0
  12. package/dist/cli/argumentChunks.js +29 -0
  13. package/dist/cli/argumentChunks.js.map +1 -0
  14. package/dist/cli/commands.d.ts +8 -0
  15. package/dist/cli/commands.d.ts.map +1 -0
  16. package/dist/cli/commands.js +56 -0
  17. package/dist/cli/commands.js.map +1 -0
  18. package/dist/cli/componentName.d.ts +32 -0
  19. package/dist/cli/componentName.d.ts.map +1 -0
  20. package/dist/cli/componentName.js +43 -0
  21. package/dist/cli/componentName.js.map +1 -0
  22. package/dist/cli/deploy.d.ts +16 -0
  23. package/dist/cli/deploy.d.ts.map +1 -0
  24. package/dist/cli/deploy.js +283 -0
  25. package/dist/cli/deploy.js.map +1 -0
  26. package/dist/cli/deployEnvironment.d.ts +10 -0
  27. package/dist/cli/deployEnvironment.d.ts.map +1 -0
  28. package/dist/cli/deployEnvironment.js +12 -0
  29. package/dist/cli/deployEnvironment.js.map +1 -0
  30. package/dist/cli/index.d.ts +12 -0
  31. package/dist/cli/index.d.ts.map +1 -0
  32. package/dist/cli/index.js +60 -0
  33. package/dist/cli/index.js.map +1 -0
  34. package/dist/cli/upload.d.ts +22 -0
  35. package/dist/cli/upload.d.ts.map +1 -0
  36. package/dist/cli/upload.js +668 -0
  37. package/dist/cli/upload.js.map +1 -0
  38. package/dist/client/_generated/_ignore.d.ts +1 -0
  39. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  40. package/dist/client/_generated/_ignore.js +3 -0
  41. package/dist/client/_generated/_ignore.js.map +1 -0
  42. package/dist/client/index.d.ts +119 -0
  43. package/dist/client/index.d.ts.map +1 -0
  44. package/dist/client/index.js +272 -0
  45. package/dist/client/index.js.map +1 -0
  46. package/dist/component/_generated/api.d.ts +38 -0
  47. package/dist/component/_generated/api.d.ts.map +1 -0
  48. package/dist/component/_generated/api.js +31 -0
  49. package/dist/component/_generated/api.js.map +1 -0
  50. package/dist/component/_generated/component.d.ts +43 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -0
  52. package/dist/component/_generated/component.js +11 -0
  53. package/dist/component/_generated/component.js.map +1 -0
  54. package/dist/component/_generated/dataModel.d.ts +46 -0
  55. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  56. package/dist/component/_generated/dataModel.js +11 -0
  57. package/dist/component/_generated/dataModel.js.map +1 -0
  58. package/dist/component/_generated/server.d.ts +121 -0
  59. package/dist/component/_generated/server.d.ts.map +1 -0
  60. package/dist/component/_generated/server.js +78 -0
  61. package/dist/component/_generated/server.js.map +1 -0
  62. package/dist/component/convex.config.d.ts +3 -0
  63. package/dist/component/convex.config.d.ts.map +1 -0
  64. package/dist/component/convex.config.js +3 -0
  65. package/dist/component/convex.config.js.map +1 -0
  66. package/dist/component/http.d.ts +4 -0
  67. package/dist/component/http.d.ts.map +1 -0
  68. package/dist/component/http.js +125 -0
  69. package/dist/component/http.js.map +1 -0
  70. package/dist/component/lib.d.ts +164 -0
  71. package/dist/component/lib.d.ts.map +1 -0
  72. package/dist/component/lib.js +768 -0
  73. package/dist/component/lib.js.map +1 -0
  74. package/dist/component/schema.d.ts +84 -0
  75. package/dist/component/schema.d.ts.map +1 -0
  76. package/dist/component/schema.js +69 -0
  77. package/dist/component/schema.js.map +1 -0
  78. package/dist/component/serving.d.ts +17 -0
  79. package/dist/component/serving.d.ts.map +1 -0
  80. package/dist/component/serving.js +150 -0
  81. package/dist/component/serving.js.map +1 -0
  82. package/dist/react/index.d.ts +59 -0
  83. package/dist/react/index.d.ts.map +1 -0
  84. package/dist/react/index.js +137 -0
  85. package/dist/react/index.js.map +1 -0
  86. package/package.json +78 -3
  87. package/src/cli/args.test.ts +79 -0
  88. package/src/cli/args.ts +153 -0
  89. package/src/cli/argumentChunks.test.ts +27 -0
  90. package/src/cli/argumentChunks.ts +40 -0
  91. package/src/cli/commands.ts +68 -0
  92. package/src/cli/componentName.test.ts +41 -0
  93. package/src/cli/componentName.ts +46 -0
  94. package/src/cli/deploy.ts +336 -0
  95. package/src/cli/deployEnvironment.test.ts +27 -0
  96. package/src/cli/deployEnvironment.ts +17 -0
  97. package/src/cli/index.ts +65 -0
  98. package/src/cli/upload.ts +885 -0
  99. package/src/client/_generated/_ignore.ts +1 -0
  100. package/src/client/index.test.ts +510 -0
  101. package/src/client/index.ts +355 -0
  102. package/src/client/setup.test.ts +22 -0
  103. package/src/component/_generated/api.ts +54 -0
  104. package/src/component/_generated/component.ts +55 -0
  105. package/src/component/_generated/dataModel.ts +60 -0
  106. package/src/component/_generated/server.ts +156 -0
  107. package/src/component/convex.config.ts +3 -0
  108. package/src/component/http.test.ts +223 -0
  109. package/src/component/http.ts +144 -0
  110. package/src/component/lib.test.ts +1007 -0
  111. package/src/component/lib.ts +862 -0
  112. package/src/component/schema.ts +75 -0
  113. package/src/component/serving.ts +158 -0
  114. package/src/component/setup.test.ts +11 -0
  115. package/src/react/index.tsx +197 -0
  116. package/src/test.ts +23 -0
@@ -0,0 +1,3 @@
1
+ import { defineComponent } from "convex/server";
2
+
3
+ export default defineComponent("hosting");
@@ -0,0 +1,223 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { afterEach, describe, expect, test, vi } from "vite-plus/test";
4
+ import { internal } from "./_generated/api.js";
5
+ import { initConvexTest } from "./setup.test.js";
6
+ import { getMountPrefix } from "./http.js";
7
+ import {
8
+ cacheControlFor,
9
+ decodeRequestPath,
10
+ etagMatches,
11
+ getMimeType,
12
+ hasFileExtension,
13
+ isHashedAsset,
14
+ } from "./serving.js";
15
+
16
+ async function storeAsset(
17
+ t: ReturnType<typeof initConvexTest>,
18
+ path: string,
19
+ body: string,
20
+ contentType: string,
21
+ deploymentId = "deploy-1",
22
+ ) {
23
+ const storageId = await t.run(async (ctx) => {
24
+ return await ctx.storage.store(new Blob([body], { type: contentType }));
25
+ });
26
+ await t.mutation(internal.lib.recordAsset, {
27
+ path,
28
+ storageId,
29
+ contentType,
30
+ deploymentId,
31
+ });
32
+ return storageId;
33
+ }
34
+
35
+ describe("static file serving", () => {
36
+ test("serves index.html at the root", async () => {
37
+ const t = initConvexTest();
38
+ await storeAsset(
39
+ t,
40
+ "/index.html",
41
+ "<!doctype html><title>hi</title>",
42
+ "text/html; charset=utf-8",
43
+ );
44
+
45
+ const res = await t.fetch("/", {});
46
+ expect(res.status).toBe(200);
47
+ expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
48
+ expect(await res.text()).toContain("<title>hi</title>");
49
+ });
50
+
51
+ test("serves an exact asset with immutable caching for hashed files", async () => {
52
+ const t = initConvexTest();
53
+ await storeAsset(
54
+ t,
55
+ "/assets/index-B71cUw87.js",
56
+ "console.log(1)",
57
+ "application/javascript; charset=utf-8",
58
+ );
59
+
60
+ const res = await t.fetch("/assets/index-B71cUw87.js", {});
61
+ expect(res.status).toBe(200);
62
+ expect(res.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
63
+ expect(res.headers.get("ETag")).toBeTruthy();
64
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
65
+ });
66
+
67
+ test("HTML is revalidated rather than cached immutably", async () => {
68
+ const t = initConvexTest();
69
+ await storeAsset(t, "/index.html", "<!doctype html>", "text/html; charset=utf-8");
70
+
71
+ const res = await t.fetch("/", {});
72
+ expect(res.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate");
73
+ });
74
+
75
+ test("returns 304 for weak or listed If-None-Match validators", async () => {
76
+ const t = initConvexTest();
77
+ await storeAsset(t, "/assets/app-B71cUw87.js", "x", "application/javascript; charset=utf-8");
78
+
79
+ const first = await t.fetch("/assets/app-B71cUw87.js", {});
80
+ const etag = first.headers.get("ETag")!;
81
+ expect(etag).toBeTruthy();
82
+
83
+ const second = await t.fetch("/assets/app-B71cUw87.js", {
84
+ headers: { "If-None-Match": `"not-current", W/${etag}` },
85
+ });
86
+ expect(second.status).toBe(304);
87
+ });
88
+
89
+ test("decodes percent-encoded asset paths", async () => {
90
+ const t = initConvexTest();
91
+ await storeAsset(t, "/docs/hello world.txt", "hello", "text/plain; charset=utf-8");
92
+
93
+ const res = await t.fetch("/docs/hello%20world.txt", {});
94
+ expect(res.status).toBe(200);
95
+ expect(await res.text()).toBe("hello");
96
+ });
97
+
98
+ test("returns 400 for malformed percent encoding", async () => {
99
+ const t = initConvexTest();
100
+ const res = await t.fetch("/bad%ZZpath", {});
101
+ expect(res.status).toBe(400);
102
+ });
103
+
104
+ test("SPA fallback serves index.html for extension-less misses", async () => {
105
+ const t = initConvexTest();
106
+ await storeAsset(t, "/index.html", "<!doctype html><div id=root>", "text/html; charset=utf-8");
107
+
108
+ const res = await t.fetch("/dashboard/settings", {});
109
+ expect(res.status).toBe(200);
110
+ expect(await res.text()).toContain("id=root");
111
+ });
112
+
113
+ test("missing files with an extension 404 (no SPA fallback)", async () => {
114
+ const t = initConvexTest();
115
+ await storeAsset(t, "/index.html", "<!doctype html>", "text/html; charset=utf-8");
116
+
117
+ const res = await t.fetch("/missing.js", {});
118
+ expect(res.status).toBe(404);
119
+ });
120
+
121
+ test("shows the setup page when nothing is deployed", async () => {
122
+ const t = initConvexTest();
123
+ const res = await t.fetch("/", {});
124
+ expect(res.status).toBe(503);
125
+ expect(res.headers.get("Cache-Control")).toBe("no-store");
126
+ expect(res.headers.get("Retry-After")).toBe("5");
127
+ expect(await res.text()).toContain("no static files have been deployed");
128
+ });
129
+
130
+ test("shows the setup page for an inherited or deleted index storage row", async () => {
131
+ const t = initConvexTest();
132
+ const storageId = await storeAsset(t, "/index.html", "legacy", "text/html; charset=utf-8");
133
+ await t.run(async (ctx) => {
134
+ await ctx.storage.delete(storageId);
135
+ });
136
+
137
+ const res = await t.fetch("/", {});
138
+ expect(res.status).toBe(503);
139
+ expect(res.headers.get("Cache-Control")).toBe("no-store");
140
+ expect(res.headers.get("Retry-After")).toBe("5");
141
+ });
142
+
143
+ test("extension-less misses 404 when SPA fallback is disabled", async () => {
144
+ const t = initConvexTest();
145
+ await storeAsset(t, "/index.html", "<!doctype html>", "text/html; charset=utf-8");
146
+ // Record the deployment with SPA fallback off (what `upload --no-spa` does).
147
+ await t.mutation(internal.lib.commitDeployment, {
148
+ currentDeploymentId: "deploy-1",
149
+ spaFallback: false,
150
+ });
151
+
152
+ const res = await t.fetch("/dashboard", {});
153
+ expect(res.status).toBe(404);
154
+ });
155
+ });
156
+
157
+ describe("serving helpers", () => {
158
+ test("getMimeType maps known extensions and defaults to octet-stream", () => {
159
+ expect(getMimeType("/index.html")).toBe("text/html; charset=utf-8");
160
+ expect(getMimeType("/a/b/style.css")).toBe("text/css; charset=utf-8");
161
+ expect(getMimeType("/favicon.ico")).toBe("image/x-icon");
162
+ expect(getMimeType("/data.unknownext")).toBe("application/octet-stream");
163
+ });
164
+
165
+ test("hasFileExtension distinguishes routes from files", () => {
166
+ expect(hasFileExtension("/index.html")).toBe(true);
167
+ expect(hasFileExtension("/assets/app.js")).toBe(true);
168
+ expect(hasFileExtension("/dashboard")).toBe(false);
169
+ expect(hasFileExtension("/dashboard/settings")).toBe(false);
170
+ // dotfiles aren't treated as having an extension
171
+ expect(hasFileExtension("/.well-known")).toBe(false);
172
+ });
173
+
174
+ test("isHashedAsset detects bundler content hashes", () => {
175
+ expect(isHashedAsset("/assets/index-lj_vq_aF.js")).toBe(true);
176
+ expect(isHashedAsset("/assets/index-D3LP-ukt.js")).toBe(true);
177
+ expect(isHashedAsset("/assets/style-B71cUw87.css")).toBe(true);
178
+ expect(isHashedAsset("/assets/inter-B71cUw87.woff2")).toBe(true);
179
+ expect(isHashedAsset("/index.html")).toBe(false);
180
+ expect(isHashedAsset("/logo.svg")).toBe(false);
181
+ expect(isHashedAsset("/privacy-policy.html")).toBe(false);
182
+ });
183
+
184
+ test("cacheControlFor is immutable only for hashed assets", () => {
185
+ expect(cacheControlFor("/assets/index-B71cUw87.js")).toBe(
186
+ "public, max-age=31536000, immutable",
187
+ );
188
+ expect(cacheControlFor("/index.html")).toBe("public, max-age=0, must-revalidate");
189
+ expect(cacheControlFor("/page-B71cUw87.html")).toBe("public, max-age=0, must-revalidate");
190
+ });
191
+
192
+ test("decodeRequestPath handles encoded and malformed paths", () => {
193
+ expect(decodeRequestPath("/hello%20world.txt")).toBe("/hello world.txt");
194
+ expect(decodeRequestPath("/bad%ZZpath")).toBeNull();
195
+ });
196
+
197
+ test("etagMatches supports weak tags, lists, and wildcard validators", () => {
198
+ expect(etagMatches('W/"asset-1"', '"asset-1"')).toBe(true);
199
+ expect(etagMatches('"other", W/"asset-1"', '"asset-1"')).toBe(true);
200
+ expect(etagMatches("*", '"asset-1"')).toBe(true);
201
+ expect(etagMatches('"other"', '"asset-1"')).toBe(false);
202
+ });
203
+
204
+ describe("getMountPrefix", () => {
205
+ afterEach(() => {
206
+ vi.unstubAllEnvs();
207
+ });
208
+
209
+ test("is empty when mounted at the root", () => {
210
+ vi.stubEnv("CONVEX_SITE_URL", "https://x.convex.site");
211
+ expect(getMountPrefix()).toBe("");
212
+ vi.stubEnv("CONVEX_SITE_URL", "https://x.convex.site/");
213
+ expect(getMountPrefix()).toBe("");
214
+ });
215
+
216
+ test("strips a trailing slash from a sub-path mount", () => {
217
+ vi.stubEnv("CONVEX_SITE_URL", "https://x.convex.site/app");
218
+ expect(getMountPrefix()).toBe("/app");
219
+ vi.stubEnv("CONVEX_SITE_URL", "https://x.convex.site/app/");
220
+ expect(getMountPrefix()).toBe("/app");
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,144 @@
1
+ import { httpRouter } from "convex/server";
2
+ import { httpAction } from "./_generated/server.js";
3
+ import { internal } from "./_generated/api.js";
4
+ import {
5
+ cacheControlFor,
6
+ decodeRequestPath,
7
+ etagMatches,
8
+ getMimeType,
9
+ getSetupHtml,
10
+ isHtmlContentType,
11
+ } from "./serving.js";
12
+
13
+ // CONVEX_SITE_URL reflects the component's mount point (including any httpPrefix).
14
+ // We use it to strip the prefix from incoming request paths so lookups in
15
+ // the asset table remain relative to the component (e.g. `/index.html`).
16
+ export function getMountPrefix(): string {
17
+ const siteUrl = process.env.CONVEX_SITE_URL;
18
+ if (!siteUrl) return "";
19
+ try {
20
+ const pathname = decodeRequestPath(new URL(siteUrl).pathname);
21
+ if (pathname === null) return "";
22
+ return pathname === "/" ? "" : pathname.replace(/\/$/, "");
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ const serveStaticFile = httpAction(async (ctx, request) => {
29
+ const url = new URL(request.url);
30
+ const decodedPath = decodeRequestPath(url.pathname);
31
+ if (decodedPath === null) {
32
+ return new Response("Bad Request", {
33
+ status: 400,
34
+ headers: { "Content-Type": "text/plain" },
35
+ });
36
+ }
37
+ let path = decodedPath;
38
+
39
+ const mountPrefix = getMountPrefix();
40
+ if (mountPrefix && path.startsWith(mountPrefix)) {
41
+ path = path.slice(mountPrefix.length) || "/";
42
+ }
43
+
44
+ if (path === "" || path === "/") {
45
+ path = "/index.html";
46
+ }
47
+
48
+ // One query resolves the asset: an exact match, or, when SPA fallback is
49
+ // enabled for the current deployment, the index.html asset for an
50
+ // extension-less miss. The fallback lookup happens inside the query so the
51
+ // HTTP action never makes a second round-trip.
52
+ const asset = await ctx.runQuery(internal.lib.resolveAsset, { path });
53
+
54
+ if (!asset) {
55
+ if (path === "/index.html") {
56
+ return new Response(getSetupHtml(), {
57
+ status: 503,
58
+ headers: {
59
+ "Content-Type": "text/html; charset=utf-8",
60
+ "Cache-Control": "no-store",
61
+ "Retry-After": "5",
62
+ },
63
+ });
64
+ }
65
+ return new Response("Not Found", {
66
+ status: 404,
67
+ headers: { "Content-Type": "text/plain" },
68
+ });
69
+ }
70
+
71
+ const contentType = asset.contentType || getMimeType(path);
72
+
73
+ // CDN redirect: blobs are served by the platform at /fs/blobs/{id}, which
74
+ // lives at the deployment root (not under the component's prefix).
75
+ if (asset.blobId && !isHtmlContentType(contentType)) {
76
+ const redirectUrl = `${url.origin}/fs/blobs/${asset.blobId}`;
77
+ return new Response(null, {
78
+ status: 302,
79
+ headers: {
80
+ Location: redirectUrl,
81
+ "Cache-Control": cacheControlFor(path),
82
+ },
83
+ });
84
+ }
85
+
86
+ if (!asset.storageId) {
87
+ return new Response("Asset not available", {
88
+ status: 500,
89
+ headers: { "Content-Type": "text/plain" },
90
+ });
91
+ }
92
+
93
+ const etag = `"${asset.storageId}"`;
94
+ const ifNoneMatch = request.headers.get("If-None-Match");
95
+ const cacheControl = cacheControlFor(path);
96
+
97
+ if (etagMatches(ifNoneMatch, etag)) {
98
+ return new Response(null, {
99
+ status: 304,
100
+ headers: { ETag: etag, "Cache-Control": cacheControl },
101
+ });
102
+ }
103
+
104
+ const blob = await ctx.storage.get(asset.storageId);
105
+ if (!blob) {
106
+ // A same-name v1 to v2 migration can temporarily inherit an index row that
107
+ // points at app-owned storage. Treat that interval like an empty component
108
+ // rather than returning a misleading permanent storage error.
109
+ if (asset.path === "/index.html") {
110
+ return new Response(getSetupHtml(), {
111
+ status: 503,
112
+ headers: {
113
+ "Content-Type": "text/html; charset=utf-8",
114
+ "Cache-Control": "no-store",
115
+ "Retry-After": "5",
116
+ },
117
+ });
118
+ }
119
+ return new Response("Storage error", {
120
+ status: 500,
121
+ headers: { "Content-Type": "text/plain" },
122
+ });
123
+ }
124
+
125
+ return new Response(blob, {
126
+ status: 200,
127
+ headers: {
128
+ "Content-Type": contentType,
129
+ "Cache-Control": cacheControl,
130
+ ETag: etag,
131
+ "X-Content-Type-Options": "nosniff",
132
+ },
133
+ });
134
+ });
135
+
136
+ const http = httpRouter();
137
+
138
+ http.route({
139
+ pathPrefix: "/",
140
+ method: "GET",
141
+ handler: serveStaticFile,
142
+ });
143
+
144
+ export default http;