@carlonicora/nextjs-jsonapi 3.28.2 → 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 (213) hide show
  1. package/dist/{BlockNoteEditor-36VL2TNM.js → BlockNoteEditor-BXMTGKDV.js} +21 -20
  2. package/dist/BlockNoteEditor-BXMTGKDV.js.map +1 -0
  3. package/dist/{BlockNoteEditor-4IDXT6ZU.mjs → BlockNoteEditor-FGVQP35E.mjs} +9 -8
  4. package/dist/{BlockNoteEditor-4IDXT6ZU.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-KOTKKBJT.js → chunk-NFCXUAE6.js} +1328 -1183
  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-HPUV6FBW.mjs → chunk-WJC4SYRL.mjs} +2131 -1986
  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/notification/contexts/NotificationContext.tsx +5 -2
  160. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +22 -0
  161. package/src/features/user/contexts/CurrentUserContext.tsx +7 -5
  162. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +63 -3
  163. package/src/hooks/__tests__/useSocket.render.test.ts +22 -0
  164. package/src/hooks/useSocket.ts +0 -1
  165. package/src/i18n/__tests__/config.test.ts +27 -0
  166. package/src/i18n/config.ts +17 -0
  167. package/src/i18n/index.ts +2 -0
  168. package/src/next-config/__tests__/createNextConfig.test.ts +268 -0
  169. package/src/next-config/csp.ts +128 -0
  170. package/src/next-config/headers.ts +61 -0
  171. package/src/next-config/index.ts +114 -0
  172. package/src/server/JsonApiServer.ts +3 -1
  173. package/src/server/ServerSession.ts +29 -1
  174. package/src/server/__tests__/JsonApiServer.test.ts +41 -0
  175. package/src/server/__tests__/ServerSession.read.test.ts +67 -0
  176. package/src/server/__tests__/cache.test.ts +48 -0
  177. package/src/server/__tests__/cachedGet.test.ts +125 -0
  178. package/src/server/__tests__/request.test.ts +96 -1
  179. package/src/server/cache.ts +21 -4
  180. package/src/server/cacheDigest.ts +10 -0
  181. package/src/server/cacheProfiles.ts +15 -0
  182. package/src/server/cachedGet.stub.ts +15 -0
  183. package/src/server/cachedGet.ts +80 -0
  184. package/src/server/request.ts +33 -32
  185. package/src/shadcnui/ui/__tests__/sidebar.menu-skeleton.test.tsx +50 -0
  186. package/src/shadcnui/ui/__tests__/sidebar.session.test.tsx +133 -0
  187. package/src/shadcnui/ui/sidebar.tsx +121 -18
  188. package/src/testing/matchers/jsonApiMatchers.ts +1 -0
  189. package/src/unified/JsonApiRequest.ts +4 -1
  190. package/src/unified/__tests__/JsonApiGet.cache.test.ts +51 -0
  191. package/src/utils/__tests__/map-thenable.test.ts +29 -0
  192. package/src/utils/__tests__/use-mobile.test.tsx +102 -1
  193. package/src/utils/map-thenable.ts +15 -0
  194. package/src/utils/use-mobile.tsx +13 -4
  195. package/dist/BlockNoteEditor-36VL2TNM.js.map +0 -1
  196. package/dist/JsonApiRequest-TE5WD3WS.js +0 -26
  197. package/dist/chunk-26MQIJNB.js.map +0 -1
  198. package/dist/chunk-4Z45NYPR.js.map +0 -1
  199. package/dist/chunk-5TRFNKJS.js.map +0 -1
  200. package/dist/chunk-7B4E42R5.mjs.map +0 -1
  201. package/dist/chunk-CAKOFJZD.js.map +0 -1
  202. package/dist/chunk-E3I4MT53.mjs.map +0 -1
  203. package/dist/chunk-GHFMKH3M.js.map +0 -1
  204. package/dist/chunk-HPUV6FBW.mjs.map +0 -1
  205. package/dist/chunk-KOTKKBJT.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/request-ZTX6I63X.js +0 -8
  209. package/dist/request-ZTX6I63X.js.map +0 -1
  210. /package/dist/{BlockNoteViewer-KRTAFTUX.mjs.map → BlockNoteViewer-DQYT6IJW.mjs.map} +0 -0
  211. /package/dist/{JsonApiRequest-UHOWNI3O.mjs.map → JsonApiRequest-MEP6AXDT.mjs.map} +0 -0
  212. /package/dist/{chunk-U65JPKO3.mjs.map → chunk-R6ZYB4VN.mjs.map} +0 -0
  213. /package/dist/{request-FXF63NSQ.mjs.map → request-3IUMNVOS.mjs.map} +0 -0
@@ -15,7 +15,11 @@ type Props = {
15
15
  deniedRedirect?: string;
16
16
  };
17
17
 
18
- /** Server page for one session's journey: role gate, then the client container. */
18
+ /**
19
+ * Server page for one session's journey: role gate, then the client container.
20
+ *
21
+ * Reads the session: render inside a `<Suspense>` boundary (Cache Components).
22
+ */
19
23
  export async function AnalyticsJourneyPage({ adminRoleId, sessionId, pageUrl, deniedRedirect = "/" }: Props) {
20
24
  if (!(await ServerSession.hasRole(adminRoleId))) {
21
25
  redirect(deniedRedirect);
@@ -63,8 +63,11 @@ export const NotificationContextProvider = ({ children }: NotificationContextPro
63
63
  // Access current user for initial load check
64
64
  const { currentUser } = useCurrentUserContext();
65
65
 
66
- // Calculate shouldRefresh (5 minute cache)
67
- const shouldRefresh = Date.now() - lastLoaded > 5 * 60 * 1000;
66
+ // Calculate shouldRefresh (5 minute cache). Never loaded short-circuits before
67
+ // Date.now(): under Cache Components a clock read during render marks the
68
+ // enclosing boundary dynamic, which kept a layout's whole app frame out of the
69
+ // prerendered shell. Same result: Date.now() - 0 always exceeds 5 minutes.
70
+ const shouldRefresh = lastLoaded === 0 || Date.now() - lastLoaded > 5 * 60 * 1000;
68
71
 
69
72
  const addNotification = useCallback((notification: NotificationInterface) => {
70
73
  setNotifications((prev) => {
@@ -126,3 +126,25 @@ describe("NotificationContextProvider failed load", () => {
126
126
  expect(result.current.error).toBe("500:");
127
127
  });
128
128
  });
129
+
130
+ describe("NotificationContextProvider first render", () => {
131
+ beforeAll(() => {
132
+ configureI18n({
133
+ useRouter: () => stubRouter,
134
+ useTranslations: () => (key: string) => key,
135
+ usePathname: () => "/",
136
+ Link: (({ children }: { children: React.ReactNode }) => children) as any,
137
+ });
138
+ });
139
+
140
+ // Under Cache Components a clock read during render marks the enclosing
141
+ // boundary dynamic, which kept a layout's whole app frame out of the shell.
142
+ it("does not read the clock before anything has loaded, and still wants a refresh", () => {
143
+ userMock.currentUser = undefined;
144
+ const now = vi.spyOn(Date, "now");
145
+ const { result } = renderHook(() => useNotificationContext(), { wrapper });
146
+ expect(now).not.toHaveBeenCalled();
147
+ expect(result.current.shouldRefresh).toBe(true);
148
+ now.mockRestore();
149
+ });
150
+ });
@@ -3,7 +3,6 @@
3
3
  import { getCookie } from "cookies-next";
4
4
  import { useAtom } from "jotai";
5
5
  import { atomWithStorage } from "jotai/utils";
6
- import { usePathname } from "next/navigation";
7
6
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
8
7
  import { useSocketContext } from "../../../contexts/SocketContext";
9
8
  import { Modules, rehydrate } from "../../../core";
@@ -43,11 +42,12 @@ const userAtom = atomWithStorage(CURRENT_USER_STORAGE_KEY, null);
43
42
  const CurrentUserContext = createContext<CurrentUserContextType | undefined>(undefined);
44
43
 
45
44
  export const CurrentUserProvider = ({ children }: { children: React.ReactNode }) => {
46
- const path = usePathname();
47
-
48
45
  const [dehydratedUser, setDehydratedUser] = useAtom(userAtom);
49
46
 
50
- const matchUrlToModule = (_params?: { path: string }): ModuleWithPermissions | undefined => {
47
+ const matchUrlToModule = (params?: { path: string }): ModuleWithPermissions | undefined => {
48
+ const path = params?.path?.toLowerCase();
49
+ if (!path) return undefined;
50
+
51
51
  const moduleKeys = Object.getOwnPropertyNames(Modules).filter(
52
52
  (key) => key !== "prototype" && key !== "_factory" && key !== "length" && key !== "name",
53
53
  );
@@ -59,7 +59,9 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
59
59
 
60
60
  const selectedModule = descriptor.get.call(Modules);
61
61
 
62
- return path.toLowerCase().startsWith(selectedModule.pageUrl?.toLowerCase());
62
+ if (!selectedModule?.pageUrl) return false;
63
+
64
+ return path.startsWith(selectedModule.pageUrl.toLowerCase());
63
65
  });
64
66
 
65
67
  if (!matchedModuleKey) return undefined;
@@ -5,8 +5,17 @@ vi.mock("cookies-next", () => ({
5
5
  getCookie: vi.fn(() => undefined),
6
6
  }));
7
7
 
8
+ // vitest.setup.ts mocks next/navigation globally; override usePathname so it
9
+ // throws: the provider must not depend on the current pathname at all
10
+ // (hasPermissionToPath checks the path it is given).
8
11
  vi.mock("next/navigation", () => ({
9
- usePathname: () => "/",
12
+ usePathname: () => {
13
+ throw new Error("CurrentUserProvider must not call usePathname");
14
+ },
15
+ }));
16
+
17
+ const permissionsMock = vi.hoisted(() => ({
18
+ checkPermissions: vi.fn((_params: { module: { name: string } }) => true),
10
19
  }));
11
20
 
12
21
  const socketMock = vi.hoisted(() => {
@@ -49,8 +58,18 @@ vi.mock("../../../../core", () => {
49
58
  return this._availableExtraCredits ?? 0;
50
59
  }
51
60
  }
61
+ const Modules: Record<string, unknown> = { User: { name: "users" } };
62
+ // The real Modules registry exposes each module through a getter.
63
+ Object.defineProperty(Modules, "Roll", {
64
+ get: () => ({ name: "rolls", pageUrl: "/rolls" }),
65
+ enumerable: true,
66
+ });
67
+ Object.defineProperty(Modules, "Person", {
68
+ get: () => ({ name: "persons", pageUrl: "/persons" }),
69
+ enumerable: true,
70
+ });
52
71
  return {
53
- Modules: { User: { name: "users" } },
72
+ Modules,
54
73
  rehydrate: (_module: unknown, data: any) => ({
55
74
  ...data,
56
75
  company: data.company ? Object.assign(new FakeCompany(), data.company) : null,
@@ -60,7 +79,7 @@ vi.mock("../../../../core", () => {
60
79
 
61
80
  vi.mock("../../../../permissions", () => ({
62
81
  Action: { Read: "read", Write: "write", Create: "create", Update: "update", Delete: "delete" },
63
- checkPermissions: () => true,
82
+ checkPermissions: permissionsMock.checkPermissions,
64
83
  }));
65
84
 
66
85
  vi.mock("../../../../roles", () => ({
@@ -322,3 +341,44 @@ describe("CurrentUserProvider token updates", () => {
322
341
  expect(UserService.findFullUser).toHaveBeenCalledTimes(1);
323
342
  });
324
343
  });
344
+
345
+ describe("CurrentUserProvider path permissions", () => {
346
+ let ctx: ReturnType<typeof useCurrentUserContext> | undefined;
347
+ function Capture() {
348
+ ctx = useCurrentUserContext();
349
+ return null;
350
+ }
351
+
352
+ beforeEach(() => {
353
+ localStorage.clear();
354
+ vi.clearAllMocks();
355
+ ctx = undefined;
356
+ permissionsMock.checkPermissions.mockImplementation(() => true);
357
+ });
358
+
359
+ it("hasPermissionToPath checks the module of the given path, not the current page", async () => {
360
+ localStorage.setItem("user", JSON.stringify({ id: "user-1", roles: [], company: null, modules: [] }));
361
+ permissionsMock.checkPermissions.mockImplementation(({ module }) => module.name === "rolls");
362
+
363
+ render(
364
+ <CurrentUserProvider>
365
+ <Capture />
366
+ </CurrentUserProvider>,
367
+ );
368
+ await new Promise((resolve) => setTimeout(resolve, 50));
369
+
370
+ expect(ctx!.hasPermissionToPath("/persons")).toBe(false);
371
+ expect(ctx!.hasPermissionToPath("/rolls")).toBe(true);
372
+ });
373
+
374
+ it("matchUrlToModule returns undefined without a path", () => {
375
+ render(
376
+ <CurrentUserProvider>
377
+ <Capture />
378
+ </CurrentUserProvider>,
379
+ );
380
+
381
+ expect(ctx!.matchUrlToModule()).toBeUndefined();
382
+ expect(ctx!.matchUrlToModule({ path: "/rolls/123" })).toEqual({ name: "rolls", pageUrl: "/rolls" });
383
+ });
384
+ });
@@ -0,0 +1,22 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("socket.io-client", () => ({ default: vi.fn() }));
5
+
6
+ import { useSocket } from "../useSocket";
7
+
8
+ describe("useSocket render", () => {
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ });
12
+
13
+ // Under Cache Components a Math.random() during render marks the enclosing
14
+ // boundary as dynamic, so a layout's SocketProvider would keep the whole app
15
+ // frame out of the prerendered shell.
16
+ it("never calls Math.random while rendering", () => {
17
+ const random = vi.spyOn(Math, "random");
18
+ const { rerender } = renderHook(() => useSocket({ token: "" }));
19
+ rerender();
20
+ expect(random).not.toHaveBeenCalled();
21
+ });
22
+ });
@@ -34,7 +34,6 @@ interface UseSocketReturn {
34
34
  export function useSocket({ token }: UseSocketOptions): UseSocketReturn {
35
35
  const _errorCount = useRef(0);
36
36
  const shouldConnect = useRef(true);
37
- const _hookInstanceId = useRef(Math.random().toString(36).substring(2, 11));
38
37
 
39
38
  const [socket, setSocket] = useState<Socket | null>(null);
40
39
  const [isConnected, setIsConnected] = useState<boolean>(false);
@@ -0,0 +1,27 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { configureI18n, getI18nServerLocale, type I18nConfig } from "../config";
3
+
4
+ function config(getLocale: I18nConfig["getLocale"]): I18nConfig {
5
+ return {
6
+ useRouter: vi.fn(),
7
+ useTranslations: vi.fn(),
8
+ Link: vi.fn() as unknown as I18nConfig["Link"],
9
+ usePathname: vi.fn(),
10
+ getLocale,
11
+ };
12
+ }
13
+
14
+ describe("getI18nServerLocale", () => {
15
+ afterEach(() => {
16
+ configureI18n(null as unknown as I18nConfig);
17
+ });
18
+
19
+ it("returns the locale from the configured getLocale", async () => {
20
+ configureI18n(config(async () => "it"));
21
+ await expect(getI18nServerLocale()).resolves.toBe("it");
22
+ });
23
+
24
+ it("throws when i18n is not configured", async () => {
25
+ await expect(getI18nServerLocale()).rejects.toThrow(/configureI18n/);
26
+ });
27
+ });
@@ -13,6 +13,7 @@ export interface I18nRouter {
13
13
  export type UseRouterHook = () => I18nRouter;
14
14
  export type UseTranslationsHook = (namespace?: string) => (key: string, values?: Record<string, any>) => string;
15
15
  export type UseLocaleHook = () => string;
16
+ export type GetLocaleFunction = () => Promise<string>;
16
17
 
17
18
  export type UseDateFnsLocaleHook = () => any; // date-fns Locale type
18
19
  export type LinkComponent = ComponentType<{ href: string; children: React.ReactNode; [key: string]: any }>;
@@ -24,6 +25,14 @@ export interface I18nConfig {
24
25
  useDateFnsLocale?: UseDateFnsLocaleHook;
25
26
  Link: LinkComponent;
26
27
  usePathname: () => string;
28
+ /**
29
+ * Server-side locale for API requests (pass next-intl's `getLocale` from `next-intl/server`).
30
+ *
31
+ * The library never imports next-intl itself: instrumentation loads the library, and
32
+ * next-intl's `getLocale` pulls in the app's `i18n/request.ts`, which reads
33
+ * `next/root-params` — a module only allowed inside the app directory.
34
+ */
35
+ getLocale: GetLocaleFunction;
27
36
  }
28
37
 
29
38
  // Private storage
@@ -65,6 +74,14 @@ export function useI18nLocale(): string {
65
74
  return "en";
66
75
  }
67
76
 
77
+ /** The server-side locale from the app's `getLocale`. Throws when `configureI18n` has not run. */
78
+ export async function getI18nServerLocale(): Promise<string> {
79
+ if (!_config?.getLocale) {
80
+ throw new Error("i18n not configured. Call configureI18n() with getLocale at app startup.");
81
+ }
82
+ return _config.getLocale();
83
+ }
84
+
68
85
  export function useI18nDateFnsLocale(): any {
69
86
  if (_config?.useDateFnsLocale) {
70
87
  return _config.useDateFnsLocale();
package/src/i18n/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  useI18nLocale,
6
6
  useI18nDateFnsLocale,
7
7
  getI18nLink,
8
+ getI18nServerLocale,
8
9
  } from "./config";
9
10
 
10
11
  export type {
@@ -13,6 +14,7 @@ export type {
13
14
  UseRouterHook,
14
15
  UseTranslationsHook,
15
16
  UseLocaleHook,
17
+ GetLocaleFunction,
16
18
  UseDateFnsLocaleHook,
17
19
  LinkComponent,
18
20
  } from "./config";
@@ -0,0 +1,268 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ // The helper reads process.env when it builds the config, exactly as a real
6
+ // next.config.js does, so the env is fixed before the helper is imported.
7
+ const FIXED_ENV = vi.hoisted(() => {
8
+ const env = {
9
+ NEXT_PUBLIC_API_URL: "https://api.test",
10
+ NEXT_PUBLIC_ADDRESS: "https://app.test",
11
+ IMAGE_SOURCES: "https://img.test",
12
+ APP_URL: "https://app.test",
13
+ };
14
+ for (const key of [
15
+ "NEXT_PUBLIC_VAPID_PUBLIC_KEY",
16
+ "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
17
+ "NEXT_PUBLIC_CORPUS_URL",
18
+ "NEXT_PUBLIC_GTM_ID",
19
+ "E2E_BUILD",
20
+ ])
21
+ delete process.env[key];
22
+ Object.assign(process.env, env);
23
+ return env;
24
+ });
25
+
26
+ import { buildCsp, createNextConfig, type NjaNextConfigOptions } from "../index";
27
+
28
+ type Pass = "production" | "e2e" | "development";
29
+ const PASSES: Record<Pass, Record<string, string | undefined>> = {
30
+ production: { NODE_ENV: "production", E2E_BUILD: undefined },
31
+ e2e: { NODE_ENV: "production", E2E_BUILD: "true" },
32
+ development: { NODE_ENV: "development", E2E_BUILD: undefined },
33
+ };
34
+
35
+ type Json = unknown;
36
+
37
+ /** Turns a resolved config into a comparable tree: headers keyed by source and
38
+ * header name, CSP parsed into directive -> sorted, de-duplicated sources
39
+ * (source order and whitespace carry no meaning in CSP), functions as markers. */
40
+ function normalise(config: Record<string, unknown>): Record<string, Json> {
41
+ const out: Record<string, Json> = JSON.parse(
42
+ JSON.stringify(config, (key, v) => (typeof v === "function" ? `[Function ${v.name || key}]` : v)),
43
+ );
44
+ if (Array.isArray(out.headers)) {
45
+ const headers: Record<string, Record<string, Json>> = {};
46
+ for (const entry of out.headers as { source: string; headers: { key: string; value: string }[] }[]) {
47
+ const byKey: Record<string, Json> = {};
48
+ for (const { key, value } of entry.headers) {
49
+ byKey[key] = key === "Content-Security-Policy" ? parseCsp(value) : value;
50
+ }
51
+ headers[entry.source] = byKey;
52
+ }
53
+ out.headers = headers;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function parseCsp(value: string): Record<string, string[]> {
59
+ const directives: Record<string, string[]> = {};
60
+ for (const part of value.split(";")) {
61
+ const [name, ...sources] = part.trim().split(/\s+/).filter(Boolean);
62
+ if (name) directives[name] = [...new Set(sources)].sort();
63
+ }
64
+ return directives;
65
+ }
66
+
67
+ function getPath(tree: Json, dotPath: string): Json {
68
+ return dotPath.split(".").reduce<Json>((node, key) => (node as Record<string, Json> | undefined)?.[key], tree);
69
+ }
70
+
71
+ function stubPass(pass: Pass) {
72
+ for (const [key, value] of Object.entries(PASSES[pass])) vi.stubEnv(key, value);
73
+ }
74
+
75
+ async function resolveHelper(options: NjaNextConfigOptions, pass: Pass): Promise<Record<string, Json>> {
76
+ stubPass(pass);
77
+ const config = createNextConfig(options) as Record<string, unknown>;
78
+ const resolved: Record<string, unknown> = { ...config };
79
+ if (typeof config.headers === "function") resolved.headers = await (config.headers as () => Promise<unknown>)();
80
+ if (typeof config.rewrites === "function") resolved.rewrites = await (config.rewrites as () => Promise<unknown>)();
81
+ return normalise(resolved);
82
+ }
83
+
84
+ afterEach(() => {
85
+ vi.unstubAllEnvs();
86
+ });
87
+
88
+ describe("createNextConfig: behaviour", () => {
89
+ const base: NjaNextConfigOptions = { devOrigins: ["example.test"] };
90
+
91
+ it("never requires a non-builtin module", () => {
92
+ const dir = path.resolve(__dirname, "..");
93
+ const files = readdirSync(dir).filter((f) => f.endsWith(".ts"));
94
+ expect(files.sort()).toEqual(["csp.ts", "headers.ts", "index.ts"]);
95
+
96
+ const offenders: string[] = [];
97
+ for (const file of files) {
98
+ const source = readFileSync(path.join(dir, file), "utf8");
99
+ const statements = source.match(/^\s*(?:import|export)\b[^;]*?\bfrom\s*["'][^"']+["']/gm) ?? [];
100
+ for (const statement of statements) {
101
+ const specifier = statement.match(/["']([^"']+)["']$/)![1];
102
+ const typeOnly = /^\s*(?:import|export)\s+type\b/.test(statement);
103
+ if (specifier.startsWith("./") || specifier.startsWith("node:")) continue;
104
+ if (specifier === "next" && typeOnly) continue;
105
+ offenders.push(`${file}: ${statement.trim()}`);
106
+ }
107
+ for (const dynamic of source.match(/\b(?:require|import)\s*\(\s*["'][^"']+["']\s*\)/g) ?? []) {
108
+ if (!/["'](?:\.\/|node:)/.test(dynamic)) offenders.push(`${file}: ${dynamic}`);
109
+ }
110
+ }
111
+ expect(offenders).toEqual([]);
112
+ });
113
+
114
+ it("sets the Next 16.4 flags every consumer gets, and nothing from the old config", () => {
115
+ const config = createNextConfig(base) as Record<string, unknown>;
116
+ expect(config.cacheComponents).toBe(true);
117
+ expect(config.partialPrefetching).toBe(true);
118
+ expect(config.poweredByHeader).toBe(false);
119
+ expect(config.reactStrictMode).toBe(false);
120
+ expect(config.reactCompiler).toBe(false);
121
+ expect(config.turbopack).toEqual({ resolveAlias: { yjs: "./node_modules/yjs" } });
122
+ expect(config.webpack).toBeUndefined();
123
+ expect(config.experimental).toEqual(
124
+ expect.objectContaining({
125
+ authInterrupts: true,
126
+ turbopackGc: true,
127
+ turbopackLazyDynamicImports: true,
128
+ serverActions: { bodySizeLimit: "5mb" },
129
+ }),
130
+ );
131
+ expect(config.experimental).not.toHaveProperty("useCache");
132
+ });
133
+
134
+ it("derives pageExtensions, serverExternalPackages and images from the options and env", () => {
135
+ const plain = createNextConfig(base) as Record<string, unknown>;
136
+ expect(plain.pageExtensions).toEqual(["ts", "tsx"]);
137
+ expect(plain.serverExternalPackages).toBeUndefined();
138
+ expect(plain.allowedDevOrigins).toEqual(["example.test"]);
139
+ expect(plain.images).toEqual({
140
+ minimumCacheTTL: 60,
141
+ remotePatterns: [{ protocol: "https", hostname: "img.test" }],
142
+ dangerouslyAllowLocalIP: true,
143
+ });
144
+
145
+ const full = createNextConfig({ ...base, mdx: true, serwist: true, imageMinimumCacheTTL: 86400 }) as Record<
146
+ string,
147
+ unknown
148
+ >;
149
+ expect(full.pageExtensions).toEqual(["ts", "tsx", "mdx"]);
150
+ expect(full.serverExternalPackages).toEqual(["esbuild", "esbuild-wasm"]);
151
+ expect((full.images as { minimumCacheTTL: number }).minimumCacheTTL).toBe(86400);
152
+ });
153
+
154
+ it("omits HSTS and upgrade-insecure-requests when E2E_BUILD=true", async () => {
155
+ const e2e = await resolveHelper(base, "e2e");
156
+ const e2eHeaders = getPath(e2e, "headers./:path*") as Record<string, Json>;
157
+ expect(e2eHeaders["Strict-Transport-Security"]).toBeUndefined();
158
+ expect(e2eHeaders["Content-Security-Policy"]).not.toHaveProperty("upgrade-insecure-requests");
159
+ expect(e2e.distDir).toBe(".next-e2e");
160
+
161
+ const production = await resolveHelper(base, "production");
162
+ const productionHeaders = getPath(production, "headers./:path*") as Record<string, Json>;
163
+ expect(productionHeaders["Strict-Transport-Security"]).toBe("max-age=31536000; includeSubDomains");
164
+ expect(productionHeaders["Content-Security-Policy"]).toHaveProperty("upgrade-insecure-requests");
165
+ expect(production.distDir).toBeUndefined();
166
+ });
167
+
168
+ it("reactCompiler option sets both reactCompiler and turbopackRustReactCompiler", () => {
169
+ const on = createNextConfig({ ...base, reactCompiler: true });
170
+ expect(on.reactCompiler).toBe(true);
171
+ expect(on.experimental?.turbopackRustReactCompiler).toBe(true);
172
+
173
+ const off = createNextConfig(base);
174
+ expect(off.reactCompiler).toBe(false);
175
+ expect(off.experimental?.turbopackRustReactCompiler).toBeUndefined();
176
+ });
177
+
178
+ it("securityHeaders:false returns no headers()", () => {
179
+ expect(createNextConfig({ ...base, securityHeaders: false }).headers).toBeUndefined();
180
+ expect(typeof createNextConfig(base).headers).toBe("function");
181
+ });
182
+
183
+ it("applies extend last, to the finished config", () => {
184
+ const extend = vi.fn((config) => ({ ...config, output: "standalone" as const }));
185
+ const config = createNextConfig({ ...base, extend });
186
+ expect(extend).toHaveBeenCalledWith(expect.objectContaining({ cacheComponents: true }));
187
+ expect(config.output).toBe("standalone");
188
+ });
189
+ });
190
+
191
+ describe("buildCsp", () => {
192
+ const env = { ...FIXED_ENV, NODE_ENV: "production" } as unknown as NodeJS.ProcessEnv;
193
+
194
+ it("allows no third-party host unless a service names it", () => {
195
+ const csp = parseCsp(buildCsp({}, env));
196
+ expect(csp["script-src"]).toEqual(["'self'", "'unsafe-eval'", "'unsafe-inline'"]);
197
+ expect(csp["style-src"]).toEqual(["'self'", "'unsafe-inline'"]);
198
+ expect(csp["font-src"]).toEqual(["'self'"]);
199
+ expect(csp["frame-src"]).toEqual(["'self'"]);
200
+ expect(csp["worker-src"]).toEqual(["'self'", "blob:"]);
201
+ expect(buildCsp({}, env)).not.toMatch(/stripe|google|cloudflare|discord/);
202
+ });
203
+
204
+ it("adds each named service's hosts to the directives it needs", () => {
205
+ const all = parseCsp(buildCsp({ services: ["stripe", "googleMaps", "turnstile", "discord", "googleFonts"] }, env));
206
+ expect(all["script-src"]).toEqual(
207
+ expect.arrayContaining([
208
+ "https://js.stripe.com",
209
+ "https://maps.googleapis.com",
210
+ "https://challenges.cloudflare.com",
211
+ ]),
212
+ );
213
+ expect(all["img-src"]).toEqual(
214
+ expect.arrayContaining([
215
+ "https://*.stripe.com",
216
+ "https://maps.googleapis.com",
217
+ "https://maps.gstatic.com",
218
+ "https://cdn.discordapp.com",
219
+ ]),
220
+ );
221
+ expect(all["connect-src"]).toEqual(
222
+ expect.arrayContaining([
223
+ "https://api.stripe.com",
224
+ "https://maps.googleapis.com",
225
+ "https://places.googleapis.com",
226
+ "https://challenges.cloudflare.com",
227
+ ]),
228
+ );
229
+ expect(all["frame-src"]).toEqual(
230
+ expect.arrayContaining([
231
+ "https://js.stripe.com",
232
+ "https://hooks.stripe.com",
233
+ "https://challenges.cloudflare.com",
234
+ ]),
235
+ );
236
+ expect(all["style-src"]).toContain("https://fonts.googleapis.com");
237
+ expect(all["font-src"]).toContain("https://fonts.gstatic.com");
238
+
239
+ const stripeOnly = buildCsp({ services: ["stripe"] }, env);
240
+ expect(stripeOnly).toContain("https://js.stripe.com");
241
+ expect(stripeOnly).not.toMatch(/google|cloudflare|discord/);
242
+ });
243
+
244
+ it("appends extra sources per directive after the services", () => {
245
+ const csp = parseCsp(buildCsp({ services: ["stripe"], csp: { "frame-src": ["https://docs.google.com/"] } }, env));
246
+ expect(csp["frame-src"]).toEqual([
247
+ "'self'",
248
+ "https://docs.google.com/",
249
+ "https://hooks.stripe.com",
250
+ "https://js.stripe.com",
251
+ ]);
252
+ });
253
+
254
+ it("derives API, websocket, address and image sources from the env", () => {
255
+ const csp = parseCsp(buildCsp({}, env));
256
+ expect(csp["connect-src"]).toEqual(
257
+ expect.arrayContaining(["https://api.test", "wss://api.test", "https://app.test", "https://img.test"]),
258
+ );
259
+ expect(csp["img-src"]).toContain("https://img.test");
260
+ });
261
+
262
+ it("drops empty sources when env values are missing", () => {
263
+ const value = buildCsp({ csp: { "img-src": [""] } }, { NODE_ENV: "development" } as unknown as NodeJS.ProcessEnv);
264
+ expect(value).not.toMatch(/\s{2,}/);
265
+ expect(value).not.toMatch(/ ;/);
266
+ expect(value).not.toContain("upgrade-insecure-requests");
267
+ });
268
+ });