@carlonicora/nextjs-jsonapi 4.2.3 → 5.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 (41) hide show
  1. package/dist/{BlockNoteEditor-NTKPWYWW.mjs → BlockNoteEditor-7BKKGFH3.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-J533UMWG.js → BlockNoteEditor-E6HJD4HB.js} +9 -9
  3. package/dist/{BlockNoteEditor-J533UMWG.js.map → BlockNoteEditor-E6HJD4HB.js.map} +1 -1
  4. package/dist/billing/index.js +371 -371
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-NCWCC56Q.mjs → chunk-MELHUPVN.mjs} +28 -13
  7. package/dist/chunk-MELHUPVN.mjs.map +1 -0
  8. package/dist/{chunk-4EGA5UP5.js → chunk-TPGXNPGY.js} +202 -187
  9. package/dist/chunk-TPGXNPGY.js.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.js +2 -2
  13. package/dist/components/index.mjs +1 -1
  14. package/dist/contexts/index.js +2 -2
  15. package/dist/contexts/index.mjs +1 -1
  16. package/dist/features/analytics/index.js +88 -88
  17. package/dist/features/analytics/index.mjs +1 -1
  18. package/dist/features/help/index.js +31 -31
  19. package/dist/features/help/index.mjs +1 -1
  20. package/dist/features/tokenusage/index.js +75 -75
  21. package/dist/features/tokenusage/index.mjs +1 -1
  22. package/dist/next-config/index.d.mts +0 -1
  23. package/dist/next-config/index.d.ts +0 -1
  24. package/dist/next-config/index.js +0 -6
  25. package/dist/next-config/index.js.map +1 -1
  26. package/dist/next-config/index.mjs +0 -6
  27. package/dist/next-config/index.mjs.map +1 -1
  28. package/package.json +4 -2
  29. package/pwa/offline.template.html +138 -0
  30. package/pwa/sw.js +175 -0
  31. package/src/features/notification/components/notifications/PushNotificationProvider.tsx +5 -1
  32. package/src/features/notification/components/notifications/__tests__/PushNotificationProvider.spec.tsx +51 -0
  33. package/src/hooks/__tests__/usePushNotifications.spec.tsx +87 -34
  34. package/src/hooks/usePushNotifications.ts +42 -18
  35. package/src/next-config/__tests__/createNextConfig.spec.ts +7 -5
  36. package/src/next-config/index.ts +1 -8
  37. package/src/pwa/__tests__/offline.template.spec.ts +246 -0
  38. package/src/pwa/__tests__/sw.spec.ts +309 -0
  39. package/dist/chunk-4EGA5UP5.js.map +0 -1
  40. package/dist/chunk-NCWCC56Q.mjs.map +0 -1
  41. /package/dist/{BlockNoteEditor-NTKPWYWW.mjs.map → BlockNoteEditor-7BKKGFH3.mjs.map} +0 -0
@@ -2,11 +2,8 @@
2
2
 
3
3
  import { useEffect } from "react";
4
4
  import { ENV } from "../core/env";
5
- import { useCurrentUserContext } from "../contexts";
6
5
  import { UserInterface } from "../features/user/data";
7
6
  import { PushService } from "../features/push/data/push.service";
8
- import { getRoleId } from "../roles";
9
- import { getAppUrl } from "../client/config";
10
7
 
11
8
  function urlBase64ToUint8Array(base64String: string): Uint8Array {
12
9
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
@@ -19,23 +16,22 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
19
16
  return outputArray;
20
17
  }
21
18
 
22
- export default function usePushNotifications(): void {
23
- const { currentUser, hasRole } = useCurrentUserContext<UserInterface>();
19
+ export default function usePushNotifications(params: {
20
+ currentUser: UserInterface | undefined;
21
+ canSubscribe: boolean;
22
+ }): void {
23
+ const { currentUser, canSubscribe } = params;
24
+ const userId = currentUser?.id;
24
25
 
25
26
  useEffect(() => {
27
+ if (!userId || !canSubscribe) return;
28
+
26
29
  const register = async () => {
27
30
  if ("serviceWorker" in navigator && "PushManager" in window) {
28
31
  try {
29
- // Check if we've already processed push notifications for this user in this session
30
- const sessionKey = `push_registered_${currentUser?.id}`;
31
- const lastRegisteredSubscription = sessionStorage.getItem(sessionKey);
32
+ const sessionKey = `push_registered_${userId}`;
32
33
 
33
- // Dev uses a push-ONLY worker (public/sw-dev.js) with no fetch handler,
34
- // so it can't cache stale Turbopack chunks the way the prod worker
35
- // (sw.ts) would. Prod uses the full worker. Both live at scope "/", so
36
- // the push subscription is identical either way.
37
- const swUrl = ENV.IS_PRODUCTION ? `${getAppUrl()}/sw.js` : `${getAppUrl()}/sw-dev.js`;
38
- const registration = await navigator.serviceWorker.register(swUrl);
34
+ const registration = await navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
39
35
 
40
36
  // Check current permission status first
41
37
  let permission = Notification.permission;
@@ -75,8 +71,9 @@ export default function usePushNotifications(): void {
75
71
  // Create a simple hash to detect subscription changes
76
72
  const subscriptionHash = btoa(JSON.stringify(plainSubscription));
77
73
 
78
- // Only call the API if subscription has changed or this is the first registration
79
- if (lastRegisteredSubscription !== subscriptionHash) {
74
+ // Read the stored hash right before posting, not at the top: a rotation
75
+ // that lands while this registration awaits must not be posted twice.
76
+ if (sessionStorage.getItem(sessionKey) !== subscriptionHash) {
80
77
  await PushService.register({ data: plainSubscription });
81
78
  // Store the current subscription hash to avoid duplicate registrations
82
79
  sessionStorage.setItem(sessionKey, subscriptionHash);
@@ -87,6 +84,33 @@ export default function usePushNotifications(): void {
87
84
  }
88
85
  };
89
86
 
90
- if (currentUser && !hasRole(getRoleId().Administrator)) register();
91
- }, [currentUser]);
87
+ register();
88
+ }, [userId, canSubscribe]);
89
+
90
+ // When the worker re-subscribes after the push service rotates the subscription
91
+ // (pushsubscriptionchange), it cannot call the authenticated API itself: it posts
92
+ // the new subscription to one window client, and this tab re-registers it under
93
+ // the user's auth, with the same hash guard as the registration above.
94
+ useEffect(() => {
95
+ if (!userId || !canSubscribe) return;
96
+ if (!("serviceWorker" in navigator)) return;
97
+
98
+ const sessionKey = `push_registered_${userId}`;
99
+
100
+ const onMessage = (event: MessageEvent) => {
101
+ if (event.data?.type !== "PUSH_RESUBSCRIBED" || !event.data.subscription) return;
102
+
103
+ const subscription = event.data.subscription;
104
+ const subscriptionHash = btoa(JSON.stringify(subscription));
105
+ if (sessionStorage.getItem(sessionKey) === subscriptionHash) return;
106
+
107
+ void PushService.register({ data: subscription })
108
+ .then(() => sessionStorage.setItem(sessionKey, subscriptionHash))
109
+ .catch((error) => console.error("Error re-registering rotated push subscription:", error));
110
+ };
111
+
112
+ const container = navigator.serviceWorker;
113
+ container.addEventListener("message", onMessage);
114
+ return () => container.removeEventListener("message", onMessage);
115
+ }, [userId, canSubscribe]);
92
116
  }
@@ -147,12 +147,14 @@ describe("createNextConfig: behaviour", () => {
147
147
  dangerouslyAllowLocalIP: true,
148
148
  });
149
149
 
150
- const full = createNextConfig({ ...base, mdx: true, serwist: true, imageMinimumCacheTTL: 86400 }) as Record<
151
- string,
152
- unknown
153
- >;
150
+ const full = createNextConfig({
151
+ ...base,
152
+ mdx: true,
153
+ serwist: true,
154
+ imageMinimumCacheTTL: 86400,
155
+ } as never) as Record<string, unknown>;
154
156
  expect(full.pageExtensions).toEqual(["ts", "tsx", "mdx"]);
155
- expect(full.serverExternalPackages).toEqual(["esbuild", "esbuild-wasm"]);
157
+ expect(full).not.toHaveProperty("serverExternalPackages");
156
158
  expect((full.images as { minimumCacheTTL: number }).minimumCacheTTL).toBe(86400);
157
159
  });
158
160
 
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // next.config.js is loaded at RUNTIME by `next start`, and the production image
5
5
  // installs with --prod. This entry may therefore import only Node built-ins at
6
- // runtime: never next-intl, @next/mdx, serwist or any devDependency. `next` is
6
+ // runtime: never next-intl, @next/mdx or any devDependency. `next` is
7
7
  // imported for its types only.
8
8
  import type { NextConfig } from "next";
9
9
  import { buildCsp, listAppOrigins, type CspDirective, type CspService } from "./csp";
@@ -22,7 +22,6 @@ export interface NjaNextConfigOptions {
22
22
  imageMinimumCacheTTL?: number;
23
23
  permissionsPolicy?: { camera?: "none" | "self" };
24
24
  mdx?: boolean;
25
- serwist?: boolean;
26
25
  securityHeaders?: boolean;
27
26
  authInterrupts?: boolean;
28
27
  reactCompiler?: boolean;
@@ -99,12 +98,6 @@ export function createNextConfig(options: NjaNextConfigOptions): NextConfig {
99
98
  NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? "",
100
99
  ...options.env,
101
100
  },
102
- // Inlined replacement for @serwist/turbopack's withSerwist wrapper, which
103
- // does nothing but append these two entries. Requiring the package here
104
- // instead CRASHES production: it is a devDependency, next.config.js is loaded
105
- // at RUNTIME by `next start`, and the web-production image installs with
106
- // --prod. Do not re-add a require of any devDependency to this file.
107
- ...(options.serwist ? { serverExternalPackages: ["esbuild", "esbuild-wasm"] } : {}),
108
101
  // Next 16 builds AND serves dev with Turbopack, so this is what pins yjs to a
109
102
  // single instance in both modes.
110
103
  turbopack: {
@@ -0,0 +1,246 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { runInNewContext } from "node:vm";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ const TEMPLATE_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "pwa", "offline.template.html");
8
+
9
+ const PLACEHOLDERS = [
10
+ "{{APP_NAME}}",
11
+ "/*{{THEME_CSS}}*/",
12
+ "{{TITLE}}",
13
+ "{{DESCRIPTION}}",
14
+ "{{RETRY}}",
15
+ "{{OFFLINE_CONFIG}}",
16
+ ] as const;
17
+
18
+ type OfflineStrings = { title: string; description: string; retry: string };
19
+
20
+ type OfflineConfig = {
21
+ defaultTheme: string;
22
+ enableSystem: boolean;
23
+ themes: string[];
24
+ defaultLocale: string;
25
+ locales: string[];
26
+ rtl: string[];
27
+ strings: Record<string, OfflineStrings>;
28
+ };
29
+
30
+ const STRINGS: Record<string, OfflineStrings> = {
31
+ en: { title: "You're Offline", description: "Check your connection.", retry: "Try Again" },
32
+ it: { title: "Sei offline", description: "Controlla la connessione.", retry: "Riprova" },
33
+ ar: { title: "أنت غير متصل", description: "تحقق من اتصالك.", retry: "أعد المحاولة" },
34
+ };
35
+
36
+ function baseConfig(overrides: Partial<OfflineConfig> = {}): OfflineConfig {
37
+ return {
38
+ defaultTheme: "light",
39
+ enableSystem: true,
40
+ themes: ["dark", "cozy"],
41
+ defaultLocale: "en",
42
+ locales: ["en", "it", "ar"],
43
+ rtl: ["ar"],
44
+ strings: STRINGS,
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function readTemplate(): string {
50
+ return readFileSync(TEMPLATE_PATH, "utf8");
51
+ }
52
+
53
+ function countOccurrences(haystack: string, needle: string): number {
54
+ return haystack.split(needle).length - 1;
55
+ }
56
+
57
+ function fill(config: OfflineConfig): string {
58
+ return readTemplate()
59
+ .split("{{APP_NAME}}")
60
+ .join("Test App")
61
+ .split("/*{{THEME_CSS}}*/")
62
+ .join(":root { --background: white; } .dark { --background: black; }")
63
+ .split("{{TITLE}}")
64
+ .join(STRINGS.en.title)
65
+ .split("{{DESCRIPTION}}")
66
+ .join(STRINGS.en.description)
67
+ .split("{{RETRY}}")
68
+ .join(STRINGS.en.retry)
69
+ .split("{{OFFLINE_CONFIG}}")
70
+ .join(JSON.stringify(config));
71
+ }
72
+
73
+ type Env = {
74
+ stored?: string | null;
75
+ storageThrows?: boolean;
76
+ languages?: string[];
77
+ pathname?: string;
78
+ cookie?: string;
79
+ prefersDark?: boolean;
80
+ };
81
+
82
+ type RunResult = {
83
+ lang: string;
84
+ dir: string;
85
+ classes: string[];
86
+ text: Record<"title" | "description" | "retry", string>;
87
+ };
88
+
89
+ function runScript(html: string, env: Env = {}): RunResult {
90
+ const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
91
+ expect(scripts.length).toBe(1);
92
+ const body = scripts[0][1];
93
+
94
+ // Host-realm containers: the sandbox only writes primitives into them, so
95
+ // assertions never compare arrays created inside the vm context.
96
+ const classes: string[] = [];
97
+ const elements: Record<"title" | "description" | "retry", { textContent: string }> = {
98
+ title: { textContent: STRINGS.en.title },
99
+ description: { textContent: STRINGS.en.description },
100
+ retry: { textContent: STRINGS.en.retry },
101
+ };
102
+ const documentElement = {
103
+ lang: "en",
104
+ dir: "",
105
+ className: "",
106
+ classList: {
107
+ add: (name: string) => {
108
+ classes.push(String(name));
109
+ documentElement.className = classes.join(" ");
110
+ },
111
+ },
112
+ };
113
+
114
+ const sandbox: Record<string, unknown> = {
115
+ document: {
116
+ documentElement,
117
+ cookie: env.cookie ?? "",
118
+ getElementById: (id: string) => elements[id as keyof typeof elements] ?? null,
119
+ },
120
+ localStorage: {
121
+ getItem: (_key: string) => {
122
+ if (env.storageThrows) throw new Error("SecurityError");
123
+ return env.stored ?? null;
124
+ },
125
+ },
126
+ navigator: { languages: env.languages ?? [] },
127
+ location: { pathname: env.pathname ?? "/" },
128
+ matchMedia: () => ({ matches: env.prefersDark ?? false }),
129
+ };
130
+ sandbox.window = sandbox;
131
+
132
+ runInNewContext(body, sandbox);
133
+
134
+ return {
135
+ lang: String(documentElement.lang),
136
+ dir: String(documentElement.dir),
137
+ classes: classes.slice(),
138
+ text: {
139
+ title: String(elements.title.textContent),
140
+ description: String(elements.description.textContent),
141
+ retry: String(elements.retry.textContent),
142
+ },
143
+ };
144
+ }
145
+
146
+ describe("pwa/offline.template.html", () => {
147
+ it("contains no /_next/ reference, external script, stylesheet or font", () => {
148
+ const raw = readTemplate();
149
+ expect(raw).not.toContain("/_next/");
150
+ expect(raw).not.toMatch(/<script[^>]+src=/);
151
+ expect(raw).not.toMatch(/<link[^>]+href=/);
152
+ expect(raw).not.toContain("url(http");
153
+ expect(raw).not.toContain("@import");
154
+ });
155
+
156
+ it("carries every placeholder exactly once", () => {
157
+ const raw = readTemplate();
158
+ for (const placeholder of PLACEHOLDERS) {
159
+ expect(countOccurrences(raw, placeholder), placeholder).toBe(1);
160
+ }
161
+ });
162
+
163
+ it("applies a stored theme as a class", () => {
164
+ const result = runScript(fill(baseConfig()), { stored: "dark" });
165
+ expect(result.classes).toContain("dark");
166
+ });
167
+
168
+ it("uses the default theme when nothing is stored", () => {
169
+ const light = runScript(fill(baseConfig()), { stored: null });
170
+ expect(light.classes.length).toBe(0);
171
+
172
+ const cozy = runScript(fill(baseConfig({ defaultTheme: "cozy" })), { stored: null });
173
+ expect(cozy.classes).toContain("cozy");
174
+ });
175
+
176
+ it("follows the OS only for a stored system value with enableSystem", () => {
177
+ const followsOs = runScript(fill(baseConfig()), { stored: "system", prefersDark: true });
178
+ expect(followsOs.classes).toContain("dark");
179
+
180
+ const noSystem = runScript(fill(baseConfig({ enableSystem: false })), { stored: "system", prefersDark: true });
181
+ expect(noSystem.classes.length).toBe(0);
182
+
183
+ const noSystemCozy = runScript(fill(baseConfig({ enableSystem: false, defaultTheme: "cozy" })), {
184
+ stored: "system",
185
+ prefersDark: true,
186
+ });
187
+ expect(noSystemCozy.classes.join(" ")).toBe("cozy");
188
+ });
189
+
190
+ it("falls back to the default theme when localStorage throws", () => {
191
+ const result = runScript(fill(baseConfig({ defaultTheme: "cozy" })), {
192
+ storageThrows: true,
193
+ pathname: "/it/sessions",
194
+ });
195
+ expect(result.classes.join(" ")).toBe("cozy");
196
+ expect(result.lang).toBe("it");
197
+ });
198
+
199
+ it("picks the locale from the URL prefix first", () => {
200
+ const result = runScript(fill(baseConfig()), {
201
+ pathname: "/it/sessions",
202
+ cookie: "NEXT_LOCALE=en",
203
+ languages: ["en-US"],
204
+ });
205
+ expect(result.lang).toBe("it");
206
+ });
207
+
208
+ it("then from the NEXT_LOCALE cookie", () => {
209
+ const result = runScript(fill(baseConfig()), {
210
+ pathname: "/sessions",
211
+ cookie: "NEXT_LOCALE=it",
212
+ languages: ["en-US"],
213
+ });
214
+ expect(result.lang).toBe("it");
215
+ });
216
+
217
+ it("then from navigator.languages", () => {
218
+ const result = runScript(fill(baseConfig()), { pathname: "/sessions", languages: ["it-IT", "en"] });
219
+ expect(result.lang).toBe("it");
220
+ });
221
+
222
+ it("skips locales the app does not ship", () => {
223
+ const result = runScript(fill(baseConfig()), {
224
+ pathname: "/fr/x",
225
+ cookie: "NEXT_LOCALE=de",
226
+ languages: ["pt-BR"],
227
+ });
228
+ expect(result.lang).toBe("en");
229
+ });
230
+
231
+ it("sets dir=rtl for an RTL locale", () => {
232
+ const arabic = runScript(fill(baseConfig()), { pathname: "/ar/x" });
233
+ expect(arabic.lang).toBe("ar");
234
+ expect(arabic.dir).toBe("rtl");
235
+
236
+ const italian = runScript(fill(baseConfig()), { pathname: "/it/x" });
237
+ expect(italian.dir).toBe("ltr");
238
+ });
239
+
240
+ it("replaces the three strings with the chosen locale's", () => {
241
+ const result = runScript(fill(baseConfig()), { pathname: "/it/x" });
242
+ expect(result.text.title).toBe(STRINGS.it.title);
243
+ expect(result.text.description).toBe(STRINGS.it.description);
244
+ expect(result.text.retry).toBe(STRINGS.it.retry);
245
+ });
246
+ });