@decocms/runtime 1.2.10 → 1.2.11
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/package.json +1 -1
- package/src/asset-server/index.test.ts +100 -0
- package/src/asset-server/index.ts +40 -3
- package/src/index.ts +17 -0
- package/src/oauth.ts +5 -2
package/package.json
CHANGED
|
@@ -155,11 +155,16 @@ describe("createAssetHandler", () => {
|
|
|
155
155
|
const indexContent = "<!DOCTYPE html><html><body>SPA</body></html>";
|
|
156
156
|
const cssContent = "body { color: red; }";
|
|
157
157
|
|
|
158
|
+
const jsContent = "export default function(){}";
|
|
159
|
+
const faviconContent = "<svg/>";
|
|
160
|
+
|
|
158
161
|
beforeAll(() => {
|
|
159
162
|
// Create temp directory with test files
|
|
160
163
|
mkdirSync(resolve(tempDir, "assets"), { recursive: true });
|
|
161
164
|
writeFileSync(resolve(tempDir, "index.html"), indexContent);
|
|
162
165
|
writeFileSync(resolve(tempDir, "assets/style.css"), cssContent);
|
|
166
|
+
writeFileSync(resolve(tempDir, "assets/chunk-AbC123.js"), jsContent);
|
|
167
|
+
writeFileSync(resolve(tempDir, "favicon.svg"), faviconContent);
|
|
163
168
|
});
|
|
164
169
|
|
|
165
170
|
afterAll(() => {
|
|
@@ -324,4 +329,99 @@ describe("createAssetHandler", () => {
|
|
|
324
329
|
expect(text).toBe(indexContent);
|
|
325
330
|
});
|
|
326
331
|
});
|
|
332
|
+
|
|
333
|
+
describe("Cache-Control headers", () => {
|
|
334
|
+
const acceptsHtml = { headers: { accept: "text/html" } };
|
|
335
|
+
|
|
336
|
+
test("index.html returns Cache-Control: no-cache", async () => {
|
|
337
|
+
const handler = createAssetHandler({
|
|
338
|
+
env: "production",
|
|
339
|
+
clientDir: tempDir,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const request = new Request("http://localhost:3000/", acceptsHtml);
|
|
343
|
+
const result = await handler(request);
|
|
344
|
+
|
|
345
|
+
expect(result).not.toBeNull();
|
|
346
|
+
expect(result!.headers.get("Cache-Control")).toBe("no-cache");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("SPA fallback returns Cache-Control: no-cache", async () => {
|
|
350
|
+
const handler = createAssetHandler({
|
|
351
|
+
env: "production",
|
|
352
|
+
clientDir: tempDir,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const request = new Request(
|
|
356
|
+
"http://localhost:3000/some/spa/route",
|
|
357
|
+
acceptsHtml,
|
|
358
|
+
);
|
|
359
|
+
const result = await handler(request);
|
|
360
|
+
|
|
361
|
+
expect(result).not.toBeNull();
|
|
362
|
+
expect(result!.headers.get("Cache-Control")).toBe("no-cache");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("hashed JS asset returns Cache-Control: immutable", async () => {
|
|
366
|
+
const handler = createAssetHandler({
|
|
367
|
+
env: "production",
|
|
368
|
+
clientDir: tempDir,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const request = new Request(
|
|
372
|
+
"http://localhost:3000/assets/chunk-AbC123.js",
|
|
373
|
+
);
|
|
374
|
+
const result = await handler(request);
|
|
375
|
+
|
|
376
|
+
expect(result).not.toBeNull();
|
|
377
|
+
expect(result!.headers.get("Cache-Control")).toBe(
|
|
378
|
+
"public, max-age=31536000, immutable",
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("CSS asset returns Cache-Control: immutable", async () => {
|
|
383
|
+
const handler = createAssetHandler({
|
|
384
|
+
env: "production",
|
|
385
|
+
clientDir: tempDir,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const request = new Request("http://localhost:3000/assets/style.css");
|
|
389
|
+
const result = await handler(request);
|
|
390
|
+
|
|
391
|
+
expect(result).not.toBeNull();
|
|
392
|
+
expect(result!.headers.get("Cache-Control")).toBe(
|
|
393
|
+
"public, max-age=31536000, immutable",
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("missing /assets/* file returns 404 with no-store to prevent CDN caching", async () => {
|
|
398
|
+
const handler = createAssetHandler({
|
|
399
|
+
env: "production",
|
|
400
|
+
clientDir: tempDir,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Request a hashed chunk that doesn't exist (simulates post-deploy stale reference)
|
|
404
|
+
const request = new Request(
|
|
405
|
+
"http://localhost:3000/assets/chunk-OldHash.js",
|
|
406
|
+
);
|
|
407
|
+
const result = await handler(request);
|
|
408
|
+
|
|
409
|
+
expect(result).not.toBeNull();
|
|
410
|
+
expect(result!.status).toBe(404);
|
|
411
|
+
expect(result!.headers.get("Cache-Control")).toBe("no-store");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("non-asset files return no Cache-Control header", async () => {
|
|
415
|
+
const handler = createAssetHandler({
|
|
416
|
+
env: "production",
|
|
417
|
+
clientDir: tempDir,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const request = new Request("http://localhost:3000/favicon.svg");
|
|
421
|
+
const result = await handler(request);
|
|
422
|
+
|
|
423
|
+
expect(result).not.toBeNull();
|
|
424
|
+
expect(result!.headers.get("Cache-Control")).toBeNull();
|
|
425
|
+
});
|
|
426
|
+
});
|
|
327
427
|
});
|
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import { devServerProxy } from "./dev-server-proxy";
|
|
2
|
-
import { resolve, dirname, join, extname } from "path";
|
|
2
|
+
import { resolve, dirname, join, extname, basename, sep } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns appropriate Cache-Control headers based on the file being served.
|
|
6
|
+
*
|
|
7
|
+
* - index.html / SPA fallback: `no-cache` so browsers always revalidate,
|
|
8
|
+
* preventing stale HTML from referencing old hashed asset URLs after deploys.
|
|
9
|
+
* - Hashed assets (/assets/*): immutable with 1-year max-age since the content
|
|
10
|
+
* hash in the filename changes on every build.
|
|
11
|
+
* - Everything else: no explicit caching directive (browser defaults apply).
|
|
12
|
+
*/
|
|
13
|
+
function getAssetCacheHeaders(
|
|
14
|
+
filePath: string,
|
|
15
|
+
indexPath: string,
|
|
16
|
+
): Record<string, string> {
|
|
17
|
+
if (filePath === indexPath || basename(filePath) === "index.html") {
|
|
18
|
+
return { "Cache-Control": "no-cache" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (filePath.includes(`${sep}assets${sep}`)) {
|
|
22
|
+
return { "Cache-Control": "public, max-age=31536000, immutable" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
3
27
|
|
|
4
28
|
export interface AssetServerConfig {
|
|
5
29
|
/**
|
|
@@ -29,7 +53,7 @@ export interface AssetServerConfig {
|
|
|
29
53
|
isServerPath?: (path: string) => boolean;
|
|
30
54
|
}
|
|
31
55
|
|
|
32
|
-
const DEFAULT_DEV_SERVER_URL =
|
|
56
|
+
const DEFAULT_DEV_SERVER_URL = `http://localhost:${process.env.VITE_PORT || "4000"}`;
|
|
33
57
|
const DEFAULT_CLIENT_DIR = "./dist/client";
|
|
34
58
|
|
|
35
59
|
/**
|
|
@@ -227,13 +251,26 @@ export function createAssetHandler(config: AssetServerConfig = {}) {
|
|
|
227
251
|
try {
|
|
228
252
|
const file = Bun.file(pathToTry);
|
|
229
253
|
if (await file.exists()) {
|
|
230
|
-
return new Response(file
|
|
254
|
+
return new Response(file, {
|
|
255
|
+
headers: getAssetCacheHeaders(pathToTry, indexPath),
|
|
256
|
+
});
|
|
231
257
|
}
|
|
232
258
|
} catch {
|
|
233
259
|
// Continue to next path
|
|
234
260
|
}
|
|
235
261
|
}
|
|
236
262
|
|
|
263
|
+
// For /assets/* paths (hashed files), return an explicit 404 with no-store
|
|
264
|
+
// to prevent CDNs (e.g., Cloudflare) from caching the 404 response.
|
|
265
|
+
// Without this, a 404 during a rolling deployment gets cached at the edge
|
|
266
|
+
// for hours, making chunks unreachable even after all pods are updated.
|
|
267
|
+
if (path.includes("/assets/")) {
|
|
268
|
+
return new Response("Not Found", {
|
|
269
|
+
status: 404,
|
|
270
|
+
headers: { "Cache-Control": "no-store" },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
237
274
|
return null;
|
|
238
275
|
};
|
|
239
276
|
}
|
package/src/index.ts
CHANGED
|
@@ -112,6 +112,8 @@ export interface RequestContext<
|
|
|
112
112
|
callerApp?: string;
|
|
113
113
|
connectionId?: string;
|
|
114
114
|
organizationId?: string;
|
|
115
|
+
organizationName?: string;
|
|
116
|
+
organizationSlug?: string;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
const withDefaultBindings = ({
|
|
@@ -191,6 +193,8 @@ export const withBindings = <TEnv>({
|
|
|
191
193
|
meshUrl?: string;
|
|
192
194
|
connectionId?: string;
|
|
193
195
|
organizationId?: string;
|
|
196
|
+
organizationName?: string;
|
|
197
|
+
organizationSlug?: string;
|
|
194
198
|
}) ?? {};
|
|
195
199
|
|
|
196
200
|
context = {
|
|
@@ -201,6 +205,10 @@ export const withBindings = <TEnv>({
|
|
|
201
205
|
connectionId: (decoded.connectionId as string) ?? metadata.connectionId,
|
|
202
206
|
organizationId:
|
|
203
207
|
(decoded.organizationId as string) ?? metadata.organizationId,
|
|
208
|
+
organizationName:
|
|
209
|
+
(decoded.organizationName as string) ?? metadata.organizationName,
|
|
210
|
+
organizationSlug:
|
|
211
|
+
(decoded.organizationSlug as string) ?? metadata.organizationSlug,
|
|
204
212
|
ensureAuthenticated: AUTHENTICATED(decoded.user ?? decoded.sub),
|
|
205
213
|
} as RequestContext<any>;
|
|
206
214
|
} else if (typeof tokenOrContext === "object") {
|
|
@@ -212,12 +220,21 @@ export const withBindings = <TEnv>({
|
|
|
212
220
|
state?: Record<string, unknown>;
|
|
213
221
|
meshUrl?: string;
|
|
214
222
|
connectionId?: string;
|
|
223
|
+
organizationId?: string;
|
|
224
|
+
organizationName?: string;
|
|
225
|
+
organizationSlug?: string;
|
|
215
226
|
}) ?? {};
|
|
216
227
|
const appName = decoded.appName as string | undefined;
|
|
217
228
|
context.authorization ??= authorization;
|
|
218
229
|
context.callerApp = appName;
|
|
219
230
|
context.connectionId ??=
|
|
220
231
|
(decoded.connectionId as string) ?? metadata.connectionId;
|
|
232
|
+
context.organizationId ??=
|
|
233
|
+
(decoded.organizationId as string) ?? metadata.organizationId;
|
|
234
|
+
context.organizationName ??=
|
|
235
|
+
(decoded.organizationName as string) ?? metadata.organizationName;
|
|
236
|
+
context.organizationSlug ??=
|
|
237
|
+
(decoded.organizationSlug as string) ?? metadata.organizationSlug;
|
|
221
238
|
context.ensureAuthenticated = AUTHENTICATED(decoded.user ?? decoded.sub);
|
|
222
239
|
} else {
|
|
223
240
|
context = {
|
package/src/oauth.ts
CHANGED
|
@@ -20,6 +20,7 @@ function isValidRedirectUri(uri: string): boolean {
|
|
|
20
20
|
return (
|
|
21
21
|
url.protocol === "https:" ||
|
|
22
22
|
url.hostname === "localhost" ||
|
|
23
|
+
url.hostname.endsWith(".localhost") ||
|
|
23
24
|
url.hostname === "127.0.0.1" ||
|
|
24
25
|
// Allow custom schemes for native apps (e.g., cursor://, vscode://)
|
|
25
26
|
!url.protocol.startsWith("http")
|
|
@@ -71,9 +72,11 @@ interface CodePayload {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
const forceHttps = (url: URL) => {
|
|
74
|
-
const isLocal =
|
|
75
|
+
const isLocal =
|
|
76
|
+
url.hostname === "localhost" ||
|
|
77
|
+
url.hostname.endsWith(".localhost") ||
|
|
78
|
+
url.hostname === "127.0.0.1";
|
|
75
79
|
if (!isLocal) {
|
|
76
|
-
// force http if not local
|
|
77
80
|
url.protocol = "https:";
|
|
78
81
|
}
|
|
79
82
|
return url;
|