@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.
- package/CHANGELOG.md +127 -0
- package/INTEGRATION.md +399 -0
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +470 -2
- package/dist/cli/args.d.ts +28 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +148 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/argumentChunks.d.ts +3 -0
- package/dist/cli/argumentChunks.d.ts.map +1 -0
- package/dist/cli/argumentChunks.js +29 -0
- package/dist/cli/argumentChunks.js.map +1 -0
- package/dist/cli/commands.d.ts +8 -0
- package/dist/cli/commands.d.ts.map +1 -0
- package/dist/cli/commands.js +56 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/componentName.d.ts +32 -0
- package/dist/cli/componentName.d.ts.map +1 -0
- package/dist/cli/componentName.js +43 -0
- package/dist/cli/componentName.js.map +1 -0
- package/dist/cli/deploy.d.ts +16 -0
- package/dist/cli/deploy.d.ts.map +1 -0
- package/dist/cli/deploy.js +283 -0
- package/dist/cli/deploy.js.map +1 -0
- package/dist/cli/deployEnvironment.d.ts +10 -0
- package/dist/cli/deployEnvironment.d.ts.map +1 -0
- package/dist/cli/deployEnvironment.js +12 -0
- package/dist/cli/deployEnvironment.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +60 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/upload.d.ts +22 -0
- package/dist/cli/upload.d.ts.map +1 -0
- package/dist/cli/upload.js +668 -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 +119 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +272 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +38 -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 +43 -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/http.d.ts +4 -0
- package/dist/component/http.d.ts.map +1 -0
- package/dist/component/http.js +125 -0
- package/dist/component/http.js.map +1 -0
- package/dist/component/lib.d.ts +164 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +768 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/schema.d.ts +84 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +69 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/serving.d.ts +17 -0
- package/dist/component/serving.d.ts.map +1 -0
- package/dist/component/serving.js +150 -0
- package/dist/component/serving.js.map +1 -0
- package/dist/react/index.d.ts +59 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +137 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +78 -3
- package/src/cli/args.test.ts +79 -0
- package/src/cli/args.ts +153 -0
- package/src/cli/argumentChunks.test.ts +27 -0
- package/src/cli/argumentChunks.ts +40 -0
- package/src/cli/commands.ts +68 -0
- package/src/cli/componentName.test.ts +41 -0
- package/src/cli/componentName.ts +46 -0
- package/src/cli/deploy.ts +336 -0
- package/src/cli/deployEnvironment.test.ts +27 -0
- package/src/cli/deployEnvironment.ts +17 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/upload.ts +885 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.test.ts +510 -0
- package/src/client/index.ts +355 -0
- package/src/client/setup.test.ts +22 -0
- package/src/component/_generated/api.ts +54 -0
- package/src/component/_generated/component.ts +55 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/http.test.ts +223 -0
- package/src/component/http.ts +144 -0
- package/src/component/lib.test.ts +1007 -0
- package/src/component/lib.ts +862 -0
- package/src/component/schema.ts +75 -0
- package/src/component/serving.ts +158 -0
- package/src/component/setup.test.ts +11 -0
- package/src/react/index.tsx +197 -0
- package/src/test.ts +23 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// This is only here so convex-test can detect a _generated folder
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
|
2
|
+
import { componentsGeneric, httpActionGeneric, httpRouter } from "convex/server";
|
|
3
|
+
import type { ComponentApi } from "../component/_generated/component.js";
|
|
4
|
+
import {
|
|
5
|
+
decodeRequestPath,
|
|
6
|
+
exposeDeploymentQuery,
|
|
7
|
+
registerStaticRoutes,
|
|
8
|
+
serveStaticAsset,
|
|
9
|
+
} from "./index.js";
|
|
10
|
+
|
|
11
|
+
const components = componentsGeneric() as unknown as {
|
|
12
|
+
hosting: ComponentApi;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type TestHttpAction = {
|
|
16
|
+
_handler: (ctx: { runQuery: ReturnType<typeof vi.fn> }, request: Request) => Promise<Response>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type TestQuery = {
|
|
20
|
+
_handler: (
|
|
21
|
+
ctx: { runQuery: ReturnType<typeof vi.fn> },
|
|
22
|
+
args: Record<string, never>,
|
|
23
|
+
) => Promise<unknown>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function invokeHandler(handler: object, runQuery: ReturnType<typeof vi.fn>, request: Request) {
|
|
27
|
+
return (handler as TestHttpAction)._handler({ runQuery }, request);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function staticHandler(path = "/") {
|
|
36
|
+
const http = httpRouter();
|
|
37
|
+
registerStaticRoutes(http, components.hosting, { pathPrefix: path });
|
|
38
|
+
const route = http.lookup(path, "GET");
|
|
39
|
+
if (!route) throw new Error(`No static route registered for ${path}`);
|
|
40
|
+
return route[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("registerStaticRoutes", () => {
|
|
44
|
+
test("keeps exact app routes ahead of the static catch-all", () => {
|
|
45
|
+
const http = httpRouter();
|
|
46
|
+
const authHandler = httpActionGeneric(async () => new Response("auth"));
|
|
47
|
+
http.route({ path: "/auth/callback", method: "GET", handler: authHandler });
|
|
48
|
+
|
|
49
|
+
registerStaticRoutes(http, components.hosting);
|
|
50
|
+
|
|
51
|
+
expect(http.lookup("/auth/callback", "GET")?.[0]).toBe(authHandler);
|
|
52
|
+
expect(http.lookup("/dashboard", "GET")?.[0]).not.toBe(authHandler);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("serves component-owned storage at the root", async () => {
|
|
56
|
+
const handler = staticHandler();
|
|
57
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
58
|
+
storageUrl: "https://storage.example/index",
|
|
59
|
+
contentType: "text/html; charset=utf-8",
|
|
60
|
+
etag: '"storage-id"',
|
|
61
|
+
});
|
|
62
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("<h1>Hello</h1>")));
|
|
63
|
+
|
|
64
|
+
const response = await invokeHandler(
|
|
65
|
+
handler,
|
|
66
|
+
runQuery,
|
|
67
|
+
new Request("https://app.convex.site/"),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(runQuery).toHaveBeenCalledWith(components.hosting.lib.resolveAssetForHttp, {
|
|
71
|
+
path: "/index.html",
|
|
72
|
+
});
|
|
73
|
+
expect(response.status).toBe(200);
|
|
74
|
+
expect(response.headers.get("ETag")).toBe('"storage-id"');
|
|
75
|
+
expect(await response.text()).toBe("<h1>Hello</h1>");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("serves an inherited v1 asset from the app's storage during migration", async () => {
|
|
79
|
+
const handler = staticHandler();
|
|
80
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
81
|
+
appStorageId: "app-storage-id",
|
|
82
|
+
contentType: "text/html; charset=utf-8",
|
|
83
|
+
etag: '"app-storage-id"',
|
|
84
|
+
});
|
|
85
|
+
const storageGet = vi.fn().mockResolvedValue(new Blob(["<h1>v1</h1>"]));
|
|
86
|
+
const fetchMock = vi.fn();
|
|
87
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
88
|
+
const ctx = { runQuery, storage: { get: storageGet } };
|
|
89
|
+
|
|
90
|
+
const response = await (
|
|
91
|
+
handler as unknown as {
|
|
92
|
+
_handler: (c: typeof ctx, r: Request) => Promise<Response>;
|
|
93
|
+
}
|
|
94
|
+
)._handler(ctx, new Request("https://app.convex.site/"));
|
|
95
|
+
|
|
96
|
+
expect(response.status).toBe(200);
|
|
97
|
+
expect(storageGet).toHaveBeenCalledWith("app-storage-id");
|
|
98
|
+
// The file is read from app storage directly, never via a storage URL fetch.
|
|
99
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
100
|
+
expect(response.headers.get("ETag")).toBe('"app-storage-id"');
|
|
101
|
+
expect(await response.text()).toBe("<h1>v1</h1>");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns 304 for an inherited v1 asset without reading app storage", async () => {
|
|
105
|
+
const handler = staticHandler();
|
|
106
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
107
|
+
appStorageId: "app-storage-id",
|
|
108
|
+
contentType: "text/html; charset=utf-8",
|
|
109
|
+
etag: '"app-storage-id"',
|
|
110
|
+
});
|
|
111
|
+
const storageGet = vi.fn();
|
|
112
|
+
const ctx = { runQuery, storage: { get: storageGet } };
|
|
113
|
+
|
|
114
|
+
const response = await (
|
|
115
|
+
handler as unknown as {
|
|
116
|
+
_handler: (c: typeof ctx, r: Request) => Promise<Response>;
|
|
117
|
+
}
|
|
118
|
+
)._handler(
|
|
119
|
+
ctx,
|
|
120
|
+
new Request("https://app.convex.site/", {
|
|
121
|
+
headers: { "If-None-Match": '"app-storage-id"' },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(response.status).toBe(304);
|
|
126
|
+
expect(storageGet).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test.each([
|
|
130
|
+
["/", 503],
|
|
131
|
+
["/legacy.js", 404],
|
|
132
|
+
])(
|
|
133
|
+
"treats an inherited v1 file missing from app storage as missing: %s",
|
|
134
|
+
async (path, status) => {
|
|
135
|
+
const handler = staticHandler();
|
|
136
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
137
|
+
appStorageId: "deleted-storage-id",
|
|
138
|
+
contentType: "application/javascript; charset=utf-8",
|
|
139
|
+
});
|
|
140
|
+
const ctx = {
|
|
141
|
+
runQuery,
|
|
142
|
+
storage: { get: vi.fn().mockResolvedValue(null) },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const response = await (
|
|
146
|
+
handler as unknown as {
|
|
147
|
+
_handler: (c: typeof ctx, r: Request) => Promise<Response>;
|
|
148
|
+
}
|
|
149
|
+
)._handler(ctx, new Request(`https://app.convex.site${path}`));
|
|
150
|
+
|
|
151
|
+
expect(response.status).toBe(status);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
test("returns a non-cacheable 503 setup page before the first upload", async () => {
|
|
156
|
+
const handler = staticHandler();
|
|
157
|
+
const runQuery = vi.fn().mockResolvedValue(null);
|
|
158
|
+
|
|
159
|
+
const response = await invokeHandler(
|
|
160
|
+
handler,
|
|
161
|
+
runQuery,
|
|
162
|
+
new Request("https://app.convex.site/"),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(response.status).toBe(503);
|
|
166
|
+
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
|
167
|
+
expect(response.headers.get("Retry-After")).toBe("5");
|
|
168
|
+
expect(await response.text()).toContain("no static files have been deployed");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("returns the setup page at the exact compatibility prefix", async () => {
|
|
172
|
+
const http = httpRouter();
|
|
173
|
+
registerStaticRoutes(http, components.hosting, {
|
|
174
|
+
pathPrefix: "/app/",
|
|
175
|
+
});
|
|
176
|
+
const handler = http.lookup("/app", "GET")?.[0];
|
|
177
|
+
if (!handler) throw new Error("No exact prefixed route registered");
|
|
178
|
+
|
|
179
|
+
const response = await invokeHandler(
|
|
180
|
+
handler,
|
|
181
|
+
vi.fn().mockResolvedValue(null),
|
|
182
|
+
new Request("https://app.convex.site/app"),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(response.status).toBe(503);
|
|
186
|
+
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
|
187
|
+
expect(response.headers.get("Retry-After")).toBe("5");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("decodes percent-encoded paths before resolving assets", async () => {
|
|
191
|
+
const handler = staticHandler();
|
|
192
|
+
const runQuery = vi.fn().mockResolvedValue(null);
|
|
193
|
+
|
|
194
|
+
await invokeHandler(
|
|
195
|
+
handler,
|
|
196
|
+
runQuery,
|
|
197
|
+
new Request("https://app.convex.site/docs/hello%20world.txt"),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(runQuery).toHaveBeenCalledWith(components.hosting.lib.resolveAssetForHttp, {
|
|
201
|
+
path: "/docs/hello world.txt",
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("rejects malformed percent encoding before querying the component", async () => {
|
|
206
|
+
const handler = staticHandler();
|
|
207
|
+
const runQuery = vi.fn();
|
|
208
|
+
|
|
209
|
+
const response = await invokeHandler(
|
|
210
|
+
handler,
|
|
211
|
+
runQuery,
|
|
212
|
+
new Request("https://app.convex.site/bad%ZZpath"),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(response.status).toBe(400);
|
|
216
|
+
expect(runQuery).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("strips a path prefix and forwards the SPA override", async () => {
|
|
220
|
+
const http = httpRouter();
|
|
221
|
+
registerStaticRoutes(http, components.hosting, {
|
|
222
|
+
pathPrefix: "/app/",
|
|
223
|
+
spaFallback: false,
|
|
224
|
+
});
|
|
225
|
+
const handler = http.lookup("/app/dashboard", "GET")?.[0];
|
|
226
|
+
if (!handler) throw new Error("No prefixed static route registered");
|
|
227
|
+
const runQuery = vi.fn().mockResolvedValue(null);
|
|
228
|
+
|
|
229
|
+
const response = await invokeHandler(
|
|
230
|
+
handler,
|
|
231
|
+
runQuery,
|
|
232
|
+
new Request("https://app.convex.site/app/dashboard"),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(runQuery).toHaveBeenCalledWith(components.hosting.lib.resolveAssetForHttp, {
|
|
236
|
+
path: "/dashboard",
|
|
237
|
+
spaFallback: false,
|
|
238
|
+
});
|
|
239
|
+
expect(response.status).toBe(404);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("returns 304 for a weak ETag in a validator list", async () => {
|
|
243
|
+
const handler = staticHandler();
|
|
244
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
245
|
+
storageUrl: "https://storage.example/app.js",
|
|
246
|
+
contentType: "application/javascript; charset=utf-8",
|
|
247
|
+
etag: '"storage-id"',
|
|
248
|
+
});
|
|
249
|
+
const fetchMock = vi.fn();
|
|
250
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
251
|
+
|
|
252
|
+
const response = await invokeHandler(
|
|
253
|
+
handler,
|
|
254
|
+
runQuery,
|
|
255
|
+
new Request("https://app.convex.site/app.js", {
|
|
256
|
+
headers: {
|
|
257
|
+
"If-None-Match": '"not-current", W/"storage-id"',
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(response.status).toBe(304);
|
|
263
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("preserves the custom CDN redirect option", async () => {
|
|
267
|
+
const http = httpRouter();
|
|
268
|
+
registerStaticRoutes(http, components.hosting, {
|
|
269
|
+
cdnBaseUrl: "https://cdn.example/blobs/",
|
|
270
|
+
});
|
|
271
|
+
const handler = http.lookup("/app-HASHED1.js", "GET")?.[0];
|
|
272
|
+
if (!handler) throw new Error("No static route registered");
|
|
273
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
274
|
+
blobId: "blob-1",
|
|
275
|
+
contentType: "application/javascript; charset=utf-8",
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const response = await invokeHandler(
|
|
279
|
+
handler,
|
|
280
|
+
runQuery,
|
|
281
|
+
new Request("https://app.convex.site/app-HASHED1.js"),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(response.status).toBe(302);
|
|
285
|
+
expect(response.headers.get("Location")).toBe("https://cdn.example/blobs/blob-1");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("serveStaticAsset", () => {
|
|
290
|
+
type ServeCtx = Parameters<typeof serveStaticAsset>[0];
|
|
291
|
+
|
|
292
|
+
function serveCtx(
|
|
293
|
+
runQuery: ReturnType<typeof vi.fn>,
|
|
294
|
+
storageGet: ReturnType<typeof vi.fn> = vi.fn(),
|
|
295
|
+
) {
|
|
296
|
+
return { runQuery, storage: { get: storageGet } } as unknown as ServeCtx;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
test("serves an exact file with the registerStaticRoutes headers", async () => {
|
|
300
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
301
|
+
storageUrl: "https://storage.example/app",
|
|
302
|
+
contentType: "application/javascript; charset=utf-8",
|
|
303
|
+
etag: '"storage-id"',
|
|
304
|
+
});
|
|
305
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("app()")));
|
|
306
|
+
|
|
307
|
+
const response = await serveStaticAsset(
|
|
308
|
+
serveCtx(runQuery),
|
|
309
|
+
components.hosting,
|
|
310
|
+
new Request("https://app.convex.site/assets/app-B71cUw87.js"),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(runQuery).toHaveBeenCalledWith(components.hosting.lib.resolveAssetForHttp, {
|
|
314
|
+
path: "/assets/app-B71cUw87.js",
|
|
315
|
+
spaFallback: false,
|
|
316
|
+
});
|
|
317
|
+
expect(response?.status).toBe(200);
|
|
318
|
+
expect(response?.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
|
319
|
+
expect(response?.headers.get("ETag")).toBe('"storage-id"');
|
|
320
|
+
expect(response?.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
321
|
+
expect(await response?.text()).toBe("app()");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("returns null instead of a setup page or 404", async () => {
|
|
325
|
+
const runQuery = vi.fn().mockResolvedValue(null);
|
|
326
|
+
|
|
327
|
+
for (const url of ["/", "/missing.js", "/dashboard"]) {
|
|
328
|
+
const response = await serveStaticAsset(
|
|
329
|
+
serveCtx(runQuery),
|
|
330
|
+
components.hosting,
|
|
331
|
+
new Request(`https://app.convex.site${url}`),
|
|
332
|
+
);
|
|
333
|
+
expect(response).toBeNull();
|
|
334
|
+
}
|
|
335
|
+
// An exact lookup: `/` is not mapped to `/index.html`.
|
|
336
|
+
expect(runQuery).toHaveBeenCalledWith(components.hosting.lib.resolveAssetForHttp, {
|
|
337
|
+
path: "/",
|
|
338
|
+
spaFallback: false,
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("serves the SPA shell only for an explicit path", async () => {
|
|
343
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
344
|
+
storageUrl: "https://storage.example/index",
|
|
345
|
+
contentType: "text/html; charset=utf-8",
|
|
346
|
+
etag: '"index-id"',
|
|
347
|
+
});
|
|
348
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("<div id=root>")));
|
|
349
|
+
|
|
350
|
+
const response = await serveStaticAsset(
|
|
351
|
+
serveCtx(runQuery),
|
|
352
|
+
components.hosting,
|
|
353
|
+
new Request("https://app.convex.site/dashboard/settings"),
|
|
354
|
+
{ path: "/index.html" },
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
expect(runQuery).toHaveBeenCalledWith(components.hosting.lib.resolveAssetForHttp, {
|
|
358
|
+
path: "/index.html",
|
|
359
|
+
spaFallback: false,
|
|
360
|
+
});
|
|
361
|
+
expect(response?.status).toBe(200);
|
|
362
|
+
expect(response?.headers.get("Cache-Control")).toBe("public, max-age=0, must-revalidate");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("forwards an explicit SPA fallback opt-in", async () => {
|
|
366
|
+
const runQuery = vi.fn().mockResolvedValue(null);
|
|
367
|
+
|
|
368
|
+
await serveStaticAsset(
|
|
369
|
+
serveCtx(runQuery),
|
|
370
|
+
components.hosting,
|
|
371
|
+
new Request("https://app.convex.site/dashboard"),
|
|
372
|
+
{ spaFallback: true },
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(runQuery).toHaveBeenCalledWith(components.hosting.lib.resolveAssetForHttp, {
|
|
376
|
+
path: "/dashboard",
|
|
377
|
+
spaFallback: true,
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("returns null for malformed percent-encoding without querying", async () => {
|
|
382
|
+
const runQuery = vi.fn();
|
|
383
|
+
|
|
384
|
+
const response = await serveStaticAsset(
|
|
385
|
+
serveCtx(runQuery),
|
|
386
|
+
components.hosting,
|
|
387
|
+
new Request("https://app.convex.site/caf%E9"),
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
expect(response).toBeNull();
|
|
391
|
+
expect(runQuery).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("rejects a path without a leading slash", async () => {
|
|
395
|
+
await expect(
|
|
396
|
+
serveStaticAsset(
|
|
397
|
+
serveCtx(vi.fn()),
|
|
398
|
+
components.hosting,
|
|
399
|
+
new Request("https://app.convex.site/about"),
|
|
400
|
+
{ path: "about/index.html" },
|
|
401
|
+
),
|
|
402
|
+
).rejects.toThrow("path must start with /");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("keeps ETag revalidation and CDN redirects", async () => {
|
|
406
|
+
const runQuery = vi
|
|
407
|
+
.fn()
|
|
408
|
+
.mockResolvedValueOnce({
|
|
409
|
+
storageUrl: "https://storage.example/app",
|
|
410
|
+
contentType: "application/javascript; charset=utf-8",
|
|
411
|
+
etag: '"storage-id"',
|
|
412
|
+
})
|
|
413
|
+
.mockResolvedValueOnce({
|
|
414
|
+
blobId: "blob-1",
|
|
415
|
+
contentType: "application/javascript; charset=utf-8",
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const revalidated = await serveStaticAsset(
|
|
419
|
+
serveCtx(runQuery),
|
|
420
|
+
components.hosting,
|
|
421
|
+
new Request("https://app.convex.site/app.js", {
|
|
422
|
+
headers: { "If-None-Match": '"storage-id"' },
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
const redirected = await serveStaticAsset(
|
|
426
|
+
serveCtx(runQuery),
|
|
427
|
+
components.hosting,
|
|
428
|
+
new Request("https://app.convex.site/app-HASHED1.js"),
|
|
429
|
+
{ cdnBaseUrl: "https://cdn.example/blobs/" },
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(revalidated?.status).toBe(304);
|
|
433
|
+
expect(redirected?.status).toBe(302);
|
|
434
|
+
expect(redirected?.headers.get("Location")).toBe("https://cdn.example/blobs/blob-1");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("returns null for an inherited v1 file missing from app storage", async () => {
|
|
438
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
439
|
+
appStorageId: "deleted-storage-id",
|
|
440
|
+
contentType: "application/javascript; charset=utf-8",
|
|
441
|
+
etag: '"deleted-storage-id"',
|
|
442
|
+
});
|
|
443
|
+
const storageGet = vi.fn().mockResolvedValue(null);
|
|
444
|
+
|
|
445
|
+
const response = await serveStaticAsset(
|
|
446
|
+
serveCtx(runQuery, storageGet),
|
|
447
|
+
components.hosting,
|
|
448
|
+
new Request("https://app.convex.site/legacy.js"),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
expect(storageGet).toHaveBeenCalledWith("deleted-storage-id");
|
|
452
|
+
expect(response).toBeNull();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("lets an app action fall through to its own rendering", async () => {
|
|
456
|
+
const render = vi.fn(
|
|
457
|
+
async (request: Request) => new Response(`rendered ${new URL(request.url).pathname}`),
|
|
458
|
+
);
|
|
459
|
+
const handler = httpActionGeneric(async (ctx, request) => {
|
|
460
|
+
const file = await serveStaticAsset(ctx, components.hosting, request);
|
|
461
|
+
if (file) return file;
|
|
462
|
+
const path = decodeRequestPath(new URL(request.url).pathname);
|
|
463
|
+
if (path === null || path.startsWith("/assets/")) {
|
|
464
|
+
return new Response("Not Found", { status: 404 });
|
|
465
|
+
}
|
|
466
|
+
return await render(request);
|
|
467
|
+
});
|
|
468
|
+
const runQuery = vi.fn().mockResolvedValue(null);
|
|
469
|
+
|
|
470
|
+
const page = await invokeHandler(
|
|
471
|
+
handler,
|
|
472
|
+
runQuery,
|
|
473
|
+
new Request("https://app.convex.site/about"),
|
|
474
|
+
);
|
|
475
|
+
// An encoded `/assets/` path is still a missing asset, not a page.
|
|
476
|
+
const staleAsset = await invokeHandler(
|
|
477
|
+
handler,
|
|
478
|
+
runQuery,
|
|
479
|
+
new Request("https://app.convex.site/%61ssets/app-OLDHASH.js"),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect(await page.text()).toBe("rendered /about");
|
|
483
|
+
expect(staleAsset.status).toBe(404);
|
|
484
|
+
expect(render).toHaveBeenCalledTimes(1);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("exposeDeploymentQuery", () => {
|
|
489
|
+
test("strips private cleanup accounting from the public result", async () => {
|
|
490
|
+
const { getCurrentDeployment } = exposeDeploymentQuery(components.hosting);
|
|
491
|
+
const runQuery = vi.fn().mockResolvedValue({
|
|
492
|
+
_id: "deployment-info-id",
|
|
493
|
+
_creationTime: 1,
|
|
494
|
+
currentDeploymentId: "deploy-1",
|
|
495
|
+
deployedAt: 2,
|
|
496
|
+
spaFallback: true,
|
|
497
|
+
pendingBlobCleanupCount: 4,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const result = await (getCurrentDeployment as unknown as TestQuery)._handler({ runQuery }, {});
|
|
501
|
+
|
|
502
|
+
expect(result).toEqual({
|
|
503
|
+
_id: "deployment-info-id",
|
|
504
|
+
_creationTime: 1,
|
|
505
|
+
currentDeploymentId: "deploy-1",
|
|
506
|
+
deployedAt: 2,
|
|
507
|
+
spaFallback: true,
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
});
|