@carlonicora/nextjs-jsonapi 3.28.1 → 4.0.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 (214) hide show
  1. package/dist/{BlockNoteEditor-ATZVHS6X.js → BlockNoteEditor-BXMTGKDV.js} +21 -20
  2. package/dist/BlockNoteEditor-BXMTGKDV.js.map +1 -0
  3. package/dist/{BlockNoteEditor-CWW2D3SG.mjs → BlockNoteEditor-FGVQP35E.mjs} +9 -8
  4. package/dist/{BlockNoteEditor-CWW2D3SG.mjs.map → BlockNoteEditor-FGVQP35E.mjs.map} +1 -1
  5. package/dist/{BlockNoteViewer-KRTAFTUX.mjs → BlockNoteViewer-DQYT6IJW.mjs} +3 -3
  6. package/dist/{BlockNoteViewer-7BFNTN4E.js → BlockNoteViewer-YH7G66PS.js} +4 -4
  7. package/dist/{BlockNoteViewer-7BFNTN4E.js.map → BlockNoteViewer-YH7G66PS.js.map} +1 -1
  8. package/dist/{HandbookSectionInterface-DgYH--Ik.d.mts → HandbookSectionInterface-BYsmnPEz.d.mts} +1 -1
  9. package/dist/{HandbookSectionInterface-Ck1wZJ81.d.ts → HandbookSectionInterface-OePKPbqN.d.ts} +1 -1
  10. package/dist/{JsonApiRequest-UHOWNI3O.mjs → JsonApiRequest-MEP6AXDT.mjs} +2 -2
  11. package/dist/JsonApiRequest-OB6WL4LA.js +26 -0
  12. package/dist/{JsonApiRequest-TE5WD3WS.js.map → JsonApiRequest-OB6WL4LA.js.map} +1 -1
  13. package/dist/ServerSession-DFNNNkg7.d.ts +29 -0
  14. package/dist/ServerSession-DK1z0_Ql.d.mts +29 -0
  15. package/dist/{analytics.modules-CfczU12P.d.ts → analytics.modules-Bsacu6ik.d.ts} +1 -1
  16. package/dist/{analytics.modules-pzGwMASI.d.mts → analytics.modules-txdCLNG-.d.mts} +1 -1
  17. package/dist/billing/index.js +404 -402
  18. package/dist/billing/index.js.map +1 -1
  19. package/dist/billing/index.mjs +10 -8
  20. package/dist/billing/index.mjs.map +1 -1
  21. package/dist/{chunk-7B4E42R5.mjs → chunk-3CZ2GK5R.mjs} +33 -9
  22. package/dist/chunk-3CZ2GK5R.mjs.map +1 -0
  23. package/dist/{chunk-4Z45NYPR.js → chunk-5W5MN7JP.js} +14 -83
  24. package/dist/chunk-5W5MN7JP.js.map +1 -0
  25. package/dist/{chunk-CAKOFJZD.js → chunk-6OFANJTE.js} +195 -142
  26. package/dist/chunk-6OFANJTE.js.map +1 -0
  27. package/dist/{chunk-5TRFNKJS.js → chunk-6TUB7Y6T.js} +23 -25
  28. package/dist/chunk-6TUB7Y6T.js.map +1 -0
  29. package/dist/{chunk-37DMFVLN.mjs → chunk-7SCREMTX.mjs} +4 -3
  30. package/dist/{chunk-37DMFVLN.mjs.map → chunk-7SCREMTX.mjs.map} +1 -1
  31. package/dist/{chunk-Q6J2ICKO.mjs → chunk-ADZSH3MG.mjs} +3 -72
  32. package/dist/chunk-ADZSH3MG.mjs.map +1 -0
  33. package/dist/{chunk-E3I4MT53.mjs → chunk-AQZ3T4BR.mjs} +71 -18
  34. package/dist/chunk-AQZ3T4BR.mjs.map +1 -0
  35. package/dist/{chunk-SJLXPHK5.mjs → chunk-E2LENTUH.mjs} +22 -24
  36. package/dist/chunk-E2LENTUH.mjs.map +1 -0
  37. package/dist/chunk-EBEXWN3I.mjs +17 -0
  38. package/dist/chunk-EBEXWN3I.mjs.map +1 -0
  39. package/dist/chunk-G4JR3SFV.js +28 -0
  40. package/dist/chunk-G4JR3SFV.js.map +1 -0
  41. package/dist/chunk-IK65TDQH.js +15 -0
  42. package/dist/chunk-IK65TDQH.js.map +1 -0
  43. package/dist/{chunk-GHFMKH3M.js → chunk-JEQ34Z5E.js} +62 -38
  44. package/dist/chunk-JEQ34Z5E.js.map +1 -0
  45. package/dist/{chunk-YDPDJIJF.js → chunk-NFCXUAE6.js} +1330 -1184
  46. package/dist/chunk-NFCXUAE6.js.map +1 -0
  47. package/dist/chunk-OEOK6A3P.js +17 -0
  48. package/dist/chunk-OEOK6A3P.js.map +1 -0
  49. package/dist/chunk-PW42LRSL.mjs +28 -0
  50. package/dist/chunk-PW42LRSL.mjs.map +1 -0
  51. package/dist/{chunk-U65JPKO3.mjs → chunk-R6ZYB4VN.mjs} +2 -2
  52. package/dist/chunk-T7SAW3UW.mjs +15 -0
  53. package/dist/chunk-T7SAW3UW.mjs.map +1 -0
  54. package/dist/{chunk-GKDHYB2R.js → chunk-TWBZ2GSX.js} +3 -3
  55. package/dist/{chunk-GKDHYB2R.js.map → chunk-TWBZ2GSX.js.map} +1 -1
  56. package/dist/{chunk-26MQIJNB.js → chunk-WGVNJXZO.js} +4 -3
  57. package/dist/chunk-WGVNJXZO.js.map +1 -0
  58. package/dist/{chunk-2IMQ5IKA.mjs → chunk-WJC4SYRL.mjs} +2133 -1987
  59. package/dist/chunk-WJC4SYRL.mjs.map +1 -0
  60. package/dist/client/index.d.mts +3 -3
  61. package/dist/client/index.d.ts +3 -3
  62. package/dist/client/index.js +6 -5
  63. package/dist/client/index.js.map +1 -1
  64. package/dist/client/index.mjs +13 -12
  65. package/dist/components/index.d.mts +16 -6
  66. package/dist/components/index.d.ts +16 -6
  67. package/dist/components/index.js +8 -5
  68. package/dist/components/index.js.map +1 -1
  69. package/dist/components/index.mjs +9 -6
  70. package/dist/{config-3N1LSrcC.d.ts → config-B6UB7j69.d.ts} +1 -1
  71. package/dist/{config-BmufyEj6.d.mts → config-CEAq4Ozy.d.mts} +10 -1
  72. package/dist/{config-AVtuvfM1.d.ts → config-DydDHCfb.d.ts} +10 -1
  73. package/dist/{config-BSKTYDoh.d.mts → config-n-ZpPmOL.d.mts} +1 -1
  74. package/dist/contexts/index.d.mts +28 -4
  75. package/dist/contexts/index.d.ts +28 -4
  76. package/dist/contexts/index.js +10 -5
  77. package/dist/contexts/index.js.map +1 -1
  78. package/dist/contexts/index.mjs +9 -4
  79. package/dist/core/index.d.mts +6 -6
  80. package/dist/core/index.d.ts +6 -6
  81. package/dist/core/index.js +5 -5
  82. package/dist/core/index.js.map +1 -1
  83. package/dist/core/index.mjs +4 -4
  84. package/dist/features/analytics/index.d.mts +2 -2
  85. package/dist/features/analytics/index.d.ts +2 -2
  86. package/dist/features/analytics/index.js +124 -123
  87. package/dist/features/analytics/index.js.map +1 -1
  88. package/dist/features/analytics/index.mjs +6 -5
  89. package/dist/features/analytics/index.mjs.map +1 -1
  90. package/dist/features/analytics/server.d.mts +10 -2
  91. package/dist/features/analytics/server.d.ts +10 -2
  92. package/dist/features/analytics/server.js +8 -6
  93. package/dist/features/analytics/server.js.map +1 -1
  94. package/dist/features/analytics/server.mjs +6 -4
  95. package/dist/features/analytics/server.mjs.map +1 -1
  96. package/dist/features/help/index.d.mts +2 -2
  97. package/dist/features/help/index.d.ts +2 -2
  98. package/dist/features/help/index.js +39 -38
  99. package/dist/features/help/index.js.map +1 -1
  100. package/dist/features/help/index.mjs +5 -4
  101. package/dist/features/help/index.mjs.map +1 -1
  102. package/dist/features/tokenusage/index.d.mts +2 -2
  103. package/dist/features/tokenusage/index.d.ts +2 -2
  104. package/dist/features/tokenusage/index.js +127 -126
  105. package/dist/features/tokenusage/index.js.map +1 -1
  106. package/dist/features/tokenusage/index.mjs +6 -5
  107. package/dist/features/tokenusage/index.mjs.map +1 -1
  108. package/dist/index.d.mts +5 -5
  109. package/dist/index.d.ts +5 -5
  110. package/dist/index.js +8 -6
  111. package/dist/index.js.map +1 -1
  112. package/dist/index.mjs +11 -9
  113. package/dist/next-config/index.d.mts +43 -0
  114. package/dist/next-config/index.d.ts +43 -0
  115. package/dist/next-config/index.js +230 -0
  116. package/dist/next-config/index.js.map +1 -0
  117. package/dist/next-config/index.mjs +230 -0
  118. package/dist/next-config/index.mjs.map +1 -0
  119. package/dist/{request-FXF63NSQ.mjs → request-3IUMNVOS.mjs} +2 -2
  120. package/dist/request-LZBAOT3Y.js +8 -0
  121. package/dist/request-LZBAOT3Y.js.map +1 -0
  122. package/dist/server/cachedGet.d.mts +28 -0
  123. package/dist/server/cachedGet.d.ts +28 -0
  124. package/dist/server/cachedGet.js +60 -0
  125. package/dist/server/cachedGet.js.map +1 -0
  126. package/dist/server/cachedGet.mjs +60 -0
  127. package/dist/server/cachedGet.mjs.map +1 -0
  128. package/dist/server/cachedGet.stub.d.mts +7 -0
  129. package/dist/server/cachedGet.stub.d.ts +7 -0
  130. package/dist/server/cachedGet.stub.js +17 -0
  131. package/dist/server/cachedGet.stub.js.map +1 -0
  132. package/dist/server/cachedGet.stub.mjs +17 -0
  133. package/dist/server/cachedGet.stub.mjs.map +1 -0
  134. package/dist/server/index.d.mts +27 -25
  135. package/dist/server/index.d.ts +27 -25
  136. package/dist/server/index.js +13 -6
  137. package/dist/server/index.js.map +1 -1
  138. package/dist/server/index.mjs +15 -8
  139. package/dist/testing/index.js.map +1 -1
  140. package/dist/testing/index.mjs.map +1 -1
  141. package/dist/{types-D0uLYV2P.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
  142. package/dist/{types-Cn153-mH.d.ts → types-bsm8Fp55.d.ts} +1 -1
  143. package/dist/{usePageUrlGenerator-B-3FRKBD.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
  144. package/dist/{usePageUrlGenerator-CCJ_cdQy.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
  145. package/package.json +12 -1
  146. package/src/components/forms/__tests__/EditorSheet.test.tsx +25 -1
  147. package/src/components/forms/__tests__/useEditorDialog.test.ts +164 -1
  148. package/src/components/forms/useEditorDialog.ts +69 -2
  149. package/src/contexts/SessionContext.tsx +20 -0
  150. package/src/contexts/SocketContext.tsx +30 -3
  151. package/src/contexts/ViewportContext.tsx +17 -2
  152. package/src/contexts/__tests__/SessionContext.test.tsx +58 -0
  153. package/src/contexts/__tests__/SocketContext.session.test.tsx +77 -0
  154. package/src/contexts/index.ts +1 -0
  155. package/src/core/abstracts/AbstractService.ts +4 -6
  156. package/src/core/abstracts/__tests__/AbstractService.locale.test.ts +64 -0
  157. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +5 -1
  158. package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +5 -1
  159. package/src/features/company/hooks/useSubscriptionStatus.ts +6 -1
  160. package/src/features/notification/contexts/NotificationContext.tsx +5 -2
  161. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +22 -0
  162. package/src/features/user/contexts/CurrentUserContext.tsx +7 -5
  163. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +63 -3
  164. package/src/hooks/__tests__/useSocket.render.test.ts +22 -0
  165. package/src/hooks/useSocket.ts +0 -1
  166. package/src/i18n/__tests__/config.test.ts +27 -0
  167. package/src/i18n/config.ts +17 -0
  168. package/src/i18n/index.ts +2 -0
  169. package/src/next-config/__tests__/createNextConfig.test.ts +268 -0
  170. package/src/next-config/csp.ts +128 -0
  171. package/src/next-config/headers.ts +61 -0
  172. package/src/next-config/index.ts +114 -0
  173. package/src/server/JsonApiServer.ts +3 -1
  174. package/src/server/ServerSession.ts +29 -1
  175. package/src/server/__tests__/JsonApiServer.test.ts +41 -0
  176. package/src/server/__tests__/ServerSession.read.test.ts +67 -0
  177. package/src/server/__tests__/cache.test.ts +48 -0
  178. package/src/server/__tests__/cachedGet.test.ts +125 -0
  179. package/src/server/__tests__/request.test.ts +96 -1
  180. package/src/server/cache.ts +21 -4
  181. package/src/server/cacheDigest.ts +10 -0
  182. package/src/server/cacheProfiles.ts +15 -0
  183. package/src/server/cachedGet.stub.ts +15 -0
  184. package/src/server/cachedGet.ts +80 -0
  185. package/src/server/request.ts +33 -32
  186. package/src/shadcnui/ui/__tests__/sidebar.menu-skeleton.test.tsx +50 -0
  187. package/src/shadcnui/ui/__tests__/sidebar.session.test.tsx +133 -0
  188. package/src/shadcnui/ui/sidebar.tsx +121 -18
  189. package/src/testing/matchers/jsonApiMatchers.ts +1 -0
  190. package/src/unified/JsonApiRequest.ts +4 -1
  191. package/src/unified/__tests__/JsonApiGet.cache.test.ts +51 -0
  192. package/src/utils/__tests__/map-thenable.test.ts +29 -0
  193. package/src/utils/__tests__/use-mobile.test.tsx +102 -1
  194. package/src/utils/map-thenable.ts +15 -0
  195. package/src/utils/use-mobile.tsx +13 -4
  196. package/dist/BlockNoteEditor-ATZVHS6X.js.map +0 -1
  197. package/dist/JsonApiRequest-TE5WD3WS.js +0 -26
  198. package/dist/chunk-26MQIJNB.js.map +0 -1
  199. package/dist/chunk-2IMQ5IKA.mjs.map +0 -1
  200. package/dist/chunk-4Z45NYPR.js.map +0 -1
  201. package/dist/chunk-5TRFNKJS.js.map +0 -1
  202. package/dist/chunk-7B4E42R5.mjs.map +0 -1
  203. package/dist/chunk-CAKOFJZD.js.map +0 -1
  204. package/dist/chunk-E3I4MT53.mjs.map +0 -1
  205. package/dist/chunk-GHFMKH3M.js.map +0 -1
  206. package/dist/chunk-Q6J2ICKO.mjs.map +0 -1
  207. package/dist/chunk-SJLXPHK5.mjs.map +0 -1
  208. package/dist/chunk-YDPDJIJF.js.map +0 -1
  209. package/dist/request-ZTX6I63X.js +0 -8
  210. package/dist/request-ZTX6I63X.js.map +0 -1
  211. /package/dist/{BlockNoteViewer-KRTAFTUX.mjs.map → BlockNoteViewer-DQYT6IJW.mjs.map} +0 -0
  212. /package/dist/{JsonApiRequest-UHOWNI3O.mjs.map → JsonApiRequest-MEP6AXDT.mjs.map} +0 -0
  213. /package/dist/{chunk-U65JPKO3.mjs.map → chunk-R6ZYB4VN.mjs.map} +0 -0
  214. /package/dist/{request-FXF63NSQ.mjs.map → request-3IUMNVOS.mjs.map} +0 -0
@@ -1,7 +1,23 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const cachedGet = vi.hoisted(() => ({ cachedServerGet: vi.fn() }));
4
+
5
+ vi.mock("../cachedGet", async () => {
6
+ const actual = await vi.importActual<typeof import("../cachedGet")>("../cachedGet");
7
+ return { ...actual, cachedServerGet: cachedGet.cachedServerGet };
8
+ });
9
+
2
10
  import { serverRequest } from "../request";
3
11
 
12
+ function okResponse(body: unknown) {
13
+ return { ok: true, status: 200, statusText: "OK", json: async () => body };
14
+ }
15
+
4
16
  describe("serverRequest", () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ });
20
+
5
21
  afterEach(() => {
6
22
  vi.unstubAllGlobals();
7
23
  });
@@ -14,4 +30,83 @@ describe("serverRequest", () => {
14
30
  expect(response.ok).toBe(false);
15
31
  expect(response.status).toBe(503);
16
32
  });
33
+
34
+ it("routes a cached GET to cachedServerGet", async () => {
35
+ const fetchMock = vi.fn();
36
+ vi.stubGlobal("fetch", fetchMock);
37
+ const cached = { data: { data: [] }, ok: true, status: 200, statusText: "OK" };
38
+ cachedGet.cachedServerGet.mockResolvedValue(cached);
39
+
40
+ const response = await serverRequest({
41
+ method: "GET",
42
+ url: "http://api.test/help",
43
+ token: "secret-token",
44
+ cache: "help",
45
+ companyId: "company-1",
46
+ language: "en",
47
+ additionalHeaders: { "x-extra": "1" },
48
+ });
49
+
50
+ expect(response).toEqual(cached);
51
+ expect(fetchMock).not.toHaveBeenCalled();
52
+ expect(cachedGet.cachedServerGet).toHaveBeenCalledWith({
53
+ url: "http://api.test/help",
54
+ cache: "help",
55
+ companyId: "company-1",
56
+ language: "en",
57
+ additionalHeaders: { "x-extra": "1" },
58
+ });
59
+ expect(JSON.stringify(cachedGet.cachedServerGet.mock.calls[0])).not.toContain("secret-token");
60
+ });
61
+
62
+ it("sends POST uncached even when cache is set", async () => {
63
+ const fetchMock = vi.fn().mockResolvedValue(okResponse({ data: {} }));
64
+ vi.stubGlobal("fetch", fetchMock);
65
+
66
+ await serverRequest({
67
+ method: "POST",
68
+ url: "http://api.test/help",
69
+ token: "secret-token",
70
+ cache: "help",
71
+ body: { a: 1 },
72
+ language: "en",
73
+ });
74
+
75
+ expect(cachedGet.cachedServerGet).not.toHaveBeenCalled();
76
+ const [, init] = fetchMock.mock.calls[0];
77
+ expect(init).not.toHaveProperty("next");
78
+ expect(init.method).toBe("POST");
79
+ expect(init.headers.Authorization).toBe("Bearer secret-token");
80
+ });
81
+
82
+ it("sends uncached GET with no next options", async () => {
83
+ const fetchMock = vi.fn().mockResolvedValue(okResponse({ data: {} }));
84
+ vi.stubGlobal("fetch", fetchMock);
85
+
86
+ await serverRequest({ method: "GET", url: "http://api.test/users", token: "secret-token", language: "en" });
87
+
88
+ expect(cachedGet.cachedServerGet).not.toHaveBeenCalled();
89
+ const [, init] = fetchMock.mock.calls[0];
90
+ expect(init).not.toHaveProperty("next");
91
+ expect(init.headers.Authorization).toBe("Bearer secret-token");
92
+ });
93
+
94
+ it("reads the status back from the digest", async () => {
95
+ cachedGet.cachedServerGet.mockRejectedValue({ digest: "HTTP_500" });
96
+
97
+ const response = await serverRequest({ method: "GET", url: "http://api.test/help", cache: "help", language: "en" });
98
+
99
+ expect(response.ok).toBe(false);
100
+ expect(response.status).toBe(500);
101
+ expect(response.data).toBeUndefined();
102
+ });
103
+
104
+ it("falls back to 503 when the digest carries no status", async () => {
105
+ cachedGet.cachedServerGet.mockRejectedValue(Object.assign(new Error("sanitised"), { digest: "2916868796" }));
106
+
107
+ const response = await serverRequest({ method: "GET", url: "http://api.test/help", cache: "help", language: "en" });
108
+
109
+ expect(response.ok).toBe(false);
110
+ expect(response.status).toBe(503);
111
+ });
17
112
  });
@@ -1,15 +1,22 @@
1
1
  // Server-only utilities (NOT server actions - these cannot be called from client components)
2
2
 
3
- import { revalidateTag } from "next/cache";
3
+ import { revalidateTag, updateTag } from "next/cache";
4
4
 
5
- export type CacheProfile = "seconds" | "minutes" | "hours" | "days" | "weeks" | "max" | "default";
5
+ import type { CacheProfile } from "./cacheProfiles";
6
+
7
+ export { CACHE_PROFILES, type CacheProfile } from "./cacheProfiles";
6
8
 
7
9
  /**
8
10
  * Revalidate a cache tag to invalidate cached data.
9
11
  * Next.js 16+ requires a profile parameter.
10
12
  *
13
+ * With `"max"` the tagged entries are marked stale and served stale-while-revalidate
14
+ * (a one-year window): the next request still gets the old data while fresh data loads
15
+ * in the background. It is not an immediate invalidation. A Server Action that must
16
+ * read its own write uses `updateCacheTag` instead.
17
+ *
11
18
  * @param tag - The cache tag to invalidate
12
- * @param profile - The cache profile (defaults to "max" for immediate invalidation)
19
+ * @param profile - The cache profile whose `expire` bounds how long stale data may be served (defaults to "max")
13
20
  */
14
21
  export async function invalidateCacheTag(tag: string, profile: CacheProfile = "max"): Promise<void> {
15
22
  revalidateTag(tag, profile);
@@ -19,10 +26,20 @@ export async function invalidateCacheTag(tag: string, profile: CacheProfile = "m
19
26
  * Revalidate multiple cache tags.
20
27
  *
21
28
  * @param tags - Array of cache tags to invalidate
22
- * @param profile - The cache profile (defaults to "max" for immediate invalidation)
29
+ * @param profile - The cache profile, see `invalidateCacheTag` (defaults to "max", stale-while-revalidate)
23
30
  */
24
31
  export async function invalidateCacheTags(tags: string[], profile: CacheProfile = "max"): Promise<void> {
25
32
  for (const tag of tags) {
26
33
  revalidateTag(tag, profile);
27
34
  }
28
35
  }
36
+
37
+ /**
38
+ * Expire a cache tag immediately, so the same request reads fresh data (read-your-own-writes).
39
+ * Wraps `updateTag`, which works in Server Actions only. Route Handlers use `invalidateCacheTag`.
40
+ *
41
+ * @param tag - The cache tag to expire
42
+ */
43
+ export async function updateCacheTag(tag: string): Promise<void> {
44
+ updateTag(tag);
45
+ }
@@ -0,0 +1,10 @@
1
+ // Shared by `cachedGet.ts` and its client stub, so `serverRequest` reads the status
2
+ // back the same way whichever one the bundler resolved.
3
+
4
+ /** Reads the HTTP status from an `HTTP_<status>` digest, or `undefined` when there is none. */
5
+ export function statusFromDigest(error: unknown): number | undefined {
6
+ const digest = (error as { digest?: unknown } | null | undefined)?.digest;
7
+ if (typeof digest !== "string") return undefined;
8
+ const match = /^HTTP_(\d+)$/.exec(digest);
9
+ return match ? Number(match[1]) : undefined;
10
+ }
@@ -0,0 +1,15 @@
1
+ // Plain constants, no `next/cache` import: `cachedGet.ts` reads them, and this file
2
+ // keeps `revalidateTag`/`updateTag` (in `cache.ts`) out of every graph that loads it.
3
+
4
+ export type CacheProfile = "seconds" | "minutes" | "hours" | "days" | "weeks" | "max" | "default";
5
+
6
+ /** The built-in `cacheLife` profile names. A module `cache` value outside this list is treated as a tag. */
7
+ export const CACHE_PROFILES: readonly CacheProfile[] = [
8
+ "seconds",
9
+ "minutes",
10
+ "hours",
11
+ "days",
12
+ "weeks",
13
+ "max",
14
+ "default",
15
+ ];
@@ -0,0 +1,15 @@
1
+ // What `@carlonicora/nextjs-jsonapi/cached-get` resolves to outside the `react-server`
2
+ // condition (client components, their SSR pass). The shared request code reaches
3
+ // `serverRequest` from client hooks too, and Next rejects `"use cache"` and
4
+ // `cacheLife`/`cacheTag` in those bundles. Nothing there calls the cached path: it
5
+ // only runs on the server, where the real `cachedGet.ts` is resolved instead.
6
+
7
+ import type { ApiData } from "../core/interfaces/ApiData";
8
+ import type { CachedGetParams } from "./cachedGet";
9
+
10
+ export { statusFromDigest } from "./cacheDigest";
11
+ export type { CachedGetParams } from "./cachedGet";
12
+
13
+ export async function cachedServerGet(_params: CachedGetParams): Promise<ApiData> {
14
+ throw new Error("cachedServerGet is server-only: it was resolved outside the react-server condition");
15
+ }
@@ -0,0 +1,80 @@
1
+ // Server-only utilities (NOT server actions - these cannot be called from client components)
2
+ //
3
+ // Reached through `@carlonicora/nextjs-jsonapi/cached-get` under the `react-server`
4
+ // condition only; every other bundle gets `cachedGet.stub.ts` (see package.json).
5
+
6
+ import { cacheLife, cacheTag } from "next/cache";
7
+ import { ApiData } from "../core/interfaces/ApiData";
8
+ import { CACHE_PROFILES, CacheProfile } from "./cacheProfiles";
9
+
10
+ export { statusFromDigest } from "./cacheDigest";
11
+
12
+ export interface CachedGetParams {
13
+ url: string;
14
+ /** A cacheLife profile name, or a tag. */
15
+ cache: string;
16
+ language: string;
17
+ companyId?: string;
18
+ additionalHeaders?: Record<string, string>;
19
+ }
20
+
21
+ function httpError(status: number, statusText: string): Error {
22
+ return Object.assign(new Error(statusText), { status, digest: `HTTP_${status}` });
23
+ }
24
+
25
+ /**
26
+ * Token-free cached GET for shared data.
27
+ *
28
+ * The arguments are the cache key, so they never carry a token, a cookie value
29
+ * or a user id. The request is sent without `Authorization`: an endpoint that
30
+ * needs a token is per-user and must not set `cache`.
31
+ *
32
+ * A non-OK response or an unreachable API throws, so Next does not store the
33
+ * entry. The status travels in `error.digest` as `HTTP_<status>` (503 when the
34
+ * API is unreachable), because `digest` is the only property that survives
35
+ * into production. Read it back with `statusFromDigest`.
36
+ */
37
+ export async function cachedServerGet(params: CachedGetParams): Promise<ApiData> {
38
+ "use cache";
39
+
40
+ if (CACHE_PROFILES.includes(params.cache as CacheProfile)) {
41
+ cacheLife(params.cache as CacheProfile);
42
+ } else {
43
+ cacheTag(params.cache);
44
+ cacheLife("max");
45
+ }
46
+
47
+ const headers: Record<string, string> = {
48
+ Accept: "application/json",
49
+ ...params.additionalHeaders,
50
+ "x-language": params.language,
51
+ };
52
+ if (params.companyId) {
53
+ headers["x-companyid"] = params.companyId;
54
+ }
55
+
56
+ let apiResponse: Response;
57
+ try {
58
+ apiResponse = await fetch(params.url, { method: "GET", headers });
59
+ } catch {
60
+ throw httpError(503, "Service Unavailable");
61
+ }
62
+
63
+ if (!apiResponse.ok) {
64
+ throw httpError(apiResponse.status, apiResponse.statusText);
65
+ }
66
+
67
+ let data: any;
68
+ try {
69
+ data = await apiResponse.json();
70
+ } catch {
71
+ data = undefined;
72
+ }
73
+
74
+ return {
75
+ data,
76
+ ok: apiResponse.ok,
77
+ status: apiResponse.status,
78
+ statusText: apiResponse.statusText,
79
+ };
80
+ }
@@ -1,6 +1,10 @@
1
1
  // Server-only utilities (NOT server actions - these cannot be called from client components)
2
2
 
3
3
  import { ApiData } from "../core/interfaces/ApiData";
4
+ // Through the package's own export, not "./cachedGet": the `react-server` condition gives
5
+ // server code the real `"use cache"` function and every other bundle a stub. A relative
6
+ // import would put `"use cache"` into the client bundles that reach this module.
7
+ import { cachedServerGet, statusFromDigest } from "@carlonicora/nextjs-jsonapi/cached-get";
4
8
 
5
9
  export interface ServerRequestParams {
6
10
  method: string;
@@ -14,22 +18,37 @@ export interface ServerRequestParams {
14
18
  additionalHeaders?: Record<string, string>;
15
19
  }
16
20
 
17
- // Map cache configuration names to revalidation times in seconds
18
- const cacheLifeToSeconds: Record<string, number> = {
19
- seconds: 1,
20
- minutes: 60,
21
- hours: 3600,
22
- days: 86400,
23
- weeks: 604800,
24
- max: 31536000, // 1 year
25
- default: 60,
26
- };
27
-
28
21
  /**
29
- * Server-side request with Next.js caching support.
30
- * Uses fetch's native caching options for Next.js compatibility.
22
+ * Server-side request.
23
+ *
24
+ * - A GET with a `cache` value goes through `cachedServerGet`, a token-free `"use cache"`
25
+ * function. The token is never sent on that path.
26
+ * - Everything else (uncached GET, POST, PUT, PATCH, DELETE) is a plain uncached `fetch`
27
+ * that sends the token.
31
28
  */
32
29
  export async function serverRequest(params: ServerRequestParams): Promise<ApiData> {
30
+ if (params.method === "GET" && params.cache) {
31
+ try {
32
+ return await cachedServerGet({
33
+ url: params.url,
34
+ cache: params.cache,
35
+ language: params.language,
36
+ companyId: params.companyId,
37
+ additionalHeaders: params.additionalHeaders,
38
+ });
39
+ } catch (error) {
40
+ // The cached function throws on failure so Next does not store it. Convert it back
41
+ // here, outside the cache scope. In production only `digest` survives the boundary.
42
+ const status = statusFromDigest(error) ?? 503;
43
+ return {
44
+ data: undefined,
45
+ ok: false,
46
+ status,
47
+ statusText: status === 503 ? "Service Unavailable" : "",
48
+ };
49
+ }
50
+ }
51
+
33
52
  const response: ApiData = {
34
53
  data: undefined,
35
54
  ok: false,
@@ -75,27 +94,9 @@ export async function serverRequest(params: ServerRequestParams): Promise<ApiDat
75
94
  additionalHeaders["Content-Type"] = "application/json";
76
95
  }
77
96
 
78
- // Build Next.js fetch caching options
79
- const nextOptions: { revalidate?: number; tags?: string[] } = {};
80
-
81
- if (params.cache) {
82
- // Check if it's a predefined cache life name
83
- if (params.cache in cacheLifeToSeconds) {
84
- nextOptions.revalidate = cacheLifeToSeconds[params.cache];
85
- } else {
86
- // Treat as a cache tag
87
- nextOptions.tags = [params.cache];
88
- nextOptions.revalidate = 60; // Default revalidation time for tagged caches
89
- }
90
- } else {
91
- // Default to short caching
92
- nextOptions.revalidate = 0;
93
- }
94
-
95
- const options: RequestInit & { next?: { revalidate?: number; tags?: string[] } } = {
97
+ const options: RequestInit = {
96
98
  method: params.method,
97
99
  headers: { Accept: "application/json", ...additionalHeaders },
98
- next: nextOptions,
99
100
  };
100
101
 
101
102
  if (requestBody !== undefined) {
@@ -0,0 +1,50 @@
1
+ import { render } from "@testing-library/react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { SidebarMenuSkeleton } from "../sidebar";
5
+
6
+ function widths(container: HTMLElement): string[] {
7
+ return Array.from(container.querySelectorAll<HTMLElement>('[data-sidebar="menu-skeleton-text"]')).map((el) =>
8
+ el.style.getPropertyValue("--skeleton-width"),
9
+ );
10
+ }
11
+
12
+ describe("SidebarMenuSkeleton", () => {
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it("never calls Math.random, so it can sit inside a prerendered shell", () => {
18
+ const random = vi.spyOn(Math, "random");
19
+ render(<SidebarMenuSkeleton showIcon />);
20
+ expect(random).not.toHaveBeenCalled();
21
+ });
22
+
23
+ it("gives every row a width between 50% and 89%", () => {
24
+ const { container } = render(
25
+ <>
26
+ {Array.from({ length: 8 }, (_, index) => (
27
+ <SidebarMenuSkeleton key={index} />
28
+ ))}
29
+ </>,
30
+ );
31
+ const values = widths(container);
32
+ expect(values).toHaveLength(8);
33
+ for (const value of values) {
34
+ const percent = Number(value.replace("%", ""));
35
+ expect(percent).toBeGreaterThanOrEqual(50);
36
+ expect(percent).toBeLessThan(90);
37
+ }
38
+ });
39
+
40
+ it("renders the same widths on every server render of the same tree, so hydration matches", () => {
41
+ const tree = (
42
+ <>
43
+ <SidebarMenuSkeleton />
44
+ <SidebarMenuSkeleton />
45
+ <SidebarMenuSkeleton />
46
+ </>
47
+ );
48
+ expect(renderToString(tree)).toEqual(renderToString(tree));
49
+ });
50
+ });
@@ -0,0 +1,133 @@
1
+ import { act, render, screen } from "@testing-library/react";
2
+ import * as React from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { SessionSnapshot } from "../../../server/ServerSession";
5
+ import { SidebarProvider, useSidebar, useSidebarControls } from "../sidebar";
6
+
7
+ function deferred<T>() {
8
+ let resolve!: (value: T) => void;
9
+ const promise = new Promise<T>((r) => {
10
+ resolve = r;
11
+ });
12
+ return { promise, resolve };
13
+ }
14
+
15
+ function snapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
16
+ return { isLogged: true, isAdministrator: false, sidebarOpen: true, isMobile: false, ...overrides };
17
+ }
18
+
19
+ function MobileReader() {
20
+ const { isMobile } = useSidebar();
21
+ return <p>{isMobile ? "mobile" : "desktop"}</p>;
22
+ }
23
+
24
+ let setOpenRef: ((open: boolean) => void) | undefined;
25
+ function StateReader() {
26
+ const { state, setOpen } = useSidebar();
27
+ setOpenRef = setOpen;
28
+ return <p data-testid="state">{state}</p>;
29
+ }
30
+
31
+ describe("SidebarProvider with a session", () => {
32
+ beforeEach(() => {
33
+ setOpenRef = undefined;
34
+ // The legacy useIsMobile path measures the viewport; jsdom has no matchMedia.
35
+ vi.stubGlobal(
36
+ "matchMedia",
37
+ vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })),
38
+ );
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ it("renders children before the session resolves", () => {
46
+ const pending = new Promise<SessionSnapshot>(() => {});
47
+ render(
48
+ <SidebarProvider session={pending}>
49
+ <p>child</p>
50
+ </SidebarProvider>,
51
+ );
52
+ expect(screen.getByText("child")).toBeInTheDocument();
53
+ });
54
+
55
+ it("useSidebarControls never suspends while the session is pending", () => {
56
+ const pending = new Promise<SessionSnapshot>(() => {});
57
+ function ControlsReader() {
58
+ const { state, toggleSidebar } = useSidebarControls();
59
+ return <p>{typeof toggleSidebar === "function" ? state : "missing"}</p>;
60
+ }
61
+ // No Suspense boundary: a suspension here would throw out of render.
62
+ render(
63
+ <SidebarProvider session={pending}>
64
+ <ControlsReader />
65
+ </SidebarProvider>,
66
+ );
67
+ expect(screen.getByText("expanded")).toBeInTheDocument();
68
+ });
69
+
70
+ it("useSidebar().isMobile suspends in the consumer and resolves", async () => {
71
+ const session = deferred<SessionSnapshot>();
72
+ // An async act around the suspending render lets React schedule the retry
73
+ // when the promise resolves; a plain render() never re-renders the reader.
74
+ await act(async () => {
75
+ render(
76
+ <SidebarProvider session={session.promise}>
77
+ <React.Suspense fallback="waiting">
78
+ <MobileReader />
79
+ </React.Suspense>
80
+ </SidebarProvider>,
81
+ );
82
+ });
83
+ expect(screen.getByText("waiting")).toBeInTheDocument();
84
+
85
+ await act(async () => {
86
+ session.resolve(snapshot({ isMobile: true, sidebarOpen: true }));
87
+ });
88
+
89
+ expect(screen.getByText("mobile")).toBeInTheDocument();
90
+ });
91
+
92
+ it("applies sidebarOpen from the session once", async () => {
93
+ const session = deferred<SessionSnapshot>();
94
+ // useSidebar() unwraps isMobile, so every consumer sits behind a boundary.
95
+ const ui = (
96
+ <SidebarProvider session={session.promise}>
97
+ <React.Suspense fallback="waiting">
98
+ <StateReader />
99
+ </React.Suspense>
100
+ </SidebarProvider>
101
+ );
102
+ let rerender!: ReturnType<typeof render>["rerender"];
103
+ await act(async () => {
104
+ ({ rerender } = render(ui));
105
+ });
106
+ expect(screen.getByText("waiting")).toBeInTheDocument();
107
+
108
+ await act(async () => {
109
+ session.resolve(snapshot({ sidebarOpen: false }));
110
+ });
111
+ expect(screen.getByTestId("state").textContent).toBe("collapsed");
112
+
113
+ act(() => {
114
+ setOpenRef!(true);
115
+ });
116
+ expect(screen.getByTestId("state").textContent).toBe("expanded");
117
+
118
+ rerender(ui);
119
+ await act(async () => {
120
+ await Promise.resolve();
121
+ });
122
+ expect(screen.getByTestId("state").textContent).toBe("expanded");
123
+ });
124
+
125
+ it("defaultOpen still works without a session", () => {
126
+ render(
127
+ <SidebarProvider defaultOpen={false}>
128
+ <StateReader />
129
+ </SidebarProvider>,
130
+ );
131
+ expect(screen.getByTestId("state").textContent).toBe("collapsed");
132
+ });
133
+ });