@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.2.10",
3
+ "version": "1.2.11",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",
@@ -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 = "http://localhost:4000";
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 = url.hostname === "localhost" || url.hostname === "127.0.0.1";
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;