@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.
- package/dist/{BlockNoteEditor-NTKPWYWW.mjs → BlockNoteEditor-7BKKGFH3.mjs} +2 -2
- package/dist/{BlockNoteEditor-J533UMWG.js → BlockNoteEditor-E6HJD4HB.js} +9 -9
- package/dist/{BlockNoteEditor-J533UMWG.js.map → BlockNoteEditor-E6HJD4HB.js.map} +1 -1
- package/dist/billing/index.js +371 -371
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-NCWCC56Q.mjs → chunk-MELHUPVN.mjs} +28 -13
- package/dist/chunk-MELHUPVN.mjs.map +1 -0
- package/dist/{chunk-4EGA5UP5.js → chunk-TPGXNPGY.js} +202 -187
- package/dist/chunk-TPGXNPGY.js.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.js +2 -2
- package/dist/components/index.mjs +1 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/features/analytics/index.js +88 -88
- package/dist/features/analytics/index.mjs +1 -1
- package/dist/features/help/index.js +31 -31
- package/dist/features/help/index.mjs +1 -1
- package/dist/features/tokenusage/index.js +75 -75
- package/dist/features/tokenusage/index.mjs +1 -1
- package/dist/next-config/index.d.mts +0 -1
- package/dist/next-config/index.d.ts +0 -1
- package/dist/next-config/index.js +0 -6
- package/dist/next-config/index.js.map +1 -1
- package/dist/next-config/index.mjs +0 -6
- package/dist/next-config/index.mjs.map +1 -1
- package/package.json +4 -2
- package/pwa/offline.template.html +138 -0
- package/pwa/sw.js +175 -0
- package/src/features/notification/components/notifications/PushNotificationProvider.tsx +5 -1
- package/src/features/notification/components/notifications/__tests__/PushNotificationProvider.spec.tsx +51 -0
- package/src/hooks/__tests__/usePushNotifications.spec.tsx +87 -34
- package/src/hooks/usePushNotifications.ts +42 -18
- package/src/next-config/__tests__/createNextConfig.spec.ts +7 -5
- package/src/next-config/index.ts +1 -8
- package/src/pwa/__tests__/offline.template.spec.ts +246 -0
- package/src/pwa/__tests__/sw.spec.ts +309 -0
- package/dist/chunk-4EGA5UP5.js.map +0 -1
- package/dist/chunk-NCWCC56Q.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-NTKPWYWW.mjs.map → BlockNoteEditor-7BKKGFH3.mjs.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/next-config/csp.ts","../../src/next-config/headers.ts","../../src/next-config/index.ts"],"sourcesContent":["// Runtime imports: none. next.config.js loads this at RUNTIME under `next start`\n// in an image installed with --prod, so it may import only Node built-ins.\n\nexport type CspDirective =\n \"script-src\" | \"style-src\" | \"img-src\" | \"font-src\" | \"connect-src\" | \"frame-src\" | \"worker-src\";\n\n/**\n * Third-party services an app may load. Each one adds its hosts to the\n * directives it needs; nothing is allowed unless the app names it.\n *\n * - `stripe`: the billing feature (`@carlonicora/nextjs-jsonapi/billing`).\n * - `googleMaps`: `FormPlaceAutocomplete` (Places API) and Maps JS/static tiles.\n * - `turnstile`: Cloudflare Turnstile widgets (contact and waitlist forms).\n * - `discord`: Discord avatars shown for users who log in with Discord.\n * - `googleFonts`: fonts linked at runtime from Google Fonts. Not needed for\n * `next/font/google`, which self-hosts at build time.\n */\nexport type CspService = \"stripe\" | \"googleMaps\" | \"turnstile\" | \"discord\" | \"googleFonts\";\n\nexport interface CspOptions {\n services?: CspService[];\n csp?: Partial<Record<CspDirective, string[]>>;\n}\n\nconst SERVICE_SOURCES: Record<CspService, Partial<Record<CspDirective, string[]>>> = {\n stripe: {\n \"script-src\": [\"https://js.stripe.com\"],\n \"img-src\": [\"https://*.stripe.com\"],\n \"connect-src\": [\"https://api.stripe.com\"],\n \"frame-src\": [\"https://js.stripe.com\", \"https://hooks.stripe.com\"],\n },\n googleMaps: {\n \"script-src\": [\"https://maps.googleapis.com\"],\n \"img-src\": [\"https://maps.googleapis.com\", \"https://maps.gstatic.com\"],\n \"connect-src\": [\"https://maps.googleapis.com\", \"https://places.googleapis.com\"],\n },\n turnstile: {\n \"script-src\": [\"https://challenges.cloudflare.com\"],\n \"connect-src\": [\"https://challenges.cloudflare.com\"],\n \"frame-src\": [\"https://challenges.cloudflare.com\"],\n },\n discord: {\n \"img-src\": [\"https://cdn.discordapp.com\"],\n },\n googleFonts: {\n \"style-src\": [\"https://fonts.googleapis.com\"],\n \"font-src\": [\"https://fonts.gstatic.com\"],\n },\n};\n\nfunction listImageSources(env: NodeJS.ProcessEnv): string[] {\n return env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(\",\").map((s) => s.trim()) : [];\n}\n\n/**\n * Origins of the app's own servers, the API and the corpus, from\n * `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_CORPUS_URL`. They serve every stored\n * object link, so images and frames must be allowed from them. Unset or invalid\n * URLs are skipped; the result has no duplicates.\n */\nexport function listAppOrigins(env: NodeJS.ProcessEnv): string[] {\n const origins: string[] = [];\n for (const url of [env.NEXT_PUBLIC_API_URL, env.NEXT_PUBLIC_CORPUS_URL]) {\n if (!url) continue;\n try {\n const { origin } = new URL(url.trim());\n if (origin !== \"null\" && !origins.includes(origin)) origins.push(origin);\n } catch {\n // Not a URL: nothing to allow.\n }\n }\n return origins;\n}\n\n// Build CSP directives\n// Note: 'unsafe-inline' and 'unsafe-eval' needed for Next.js dev mode and some runtime features\n// In production, consider using nonces for stricter CSP\nexport function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {\n const apiUrl = env.NEXT_PUBLIC_API_URL || \"\";\n const imageSources = listImageSources(env);\n const appOrigins = listAppOrigins(env);\n const services = options.services ?? [];\n\n // Each fetch directive: what Next itself needs, then the named services, then\n // the app's extras, then the sources derived from the env.\n const fetchDirectives: Record<CspDirective, { base: string[]; fromEnv: string[] }> = {\n // Scripts: self, and unsafe-inline/eval for Next.js\n \"script-src\": { base: [\"'self'\", \"'unsafe-inline'\", \"'unsafe-eval'\"], fromEnv: [] },\n // Styles: self and unsafe-inline for styled-components/emotion\n \"style-src\": { base: [\"'self'\", \"'unsafe-inline'\"], fromEnv: [] },\n // Images: self, data URIs, blobs, configured image sources, and the api/corpus origins\n \"img-src\": { base: [\"'self'\", \"data:\", \"blob:\"], fromEnv: [...imageSources, ...appOrigins] },\n // Fonts: self\n \"font-src\": { base: [\"'self'\"], fromEnv: [] },\n // Connect: self, API (http + ws for socket.io), and storage (for uploads)\n \"connect-src\": {\n base: [\"'self'\"],\n fromEnv: [\n apiUrl,\n // Add WebSocket URL for socket.io (convert http->ws, https->wss)\n apiUrl.replace(/^http/, \"ws\"),\n env.NEXT_PUBLIC_ADDRESS || \"\",\n ...imageSources,\n ],\n },\n // Frames: self, and the api/corpus origins (inline previews of stored objects)\n \"frame-src\": { base: [\"'self'\"], fromEnv: appOrigins },\n // Worker sources: self and blob (for Web Workers with dynamic imports)\n \"worker-src\": { base: [\"'self'\", \"blob:\"], fromEnv: [] },\n };\n\n const directive = (name: CspDirective): string => {\n const { base, fromEnv } = fetchDirectives[name];\n const fromServices = services.flatMap((service) => SERVICE_SOURCES[service][name] ?? []);\n const sources = [...base, ...fromServices, ...(options.csp?.[name] ?? []), ...fromEnv].filter(\n (source) => source !== \"\",\n );\n return [name, ...new Set(sources)].join(\" \");\n };\n\n return [\n \"default-src 'self'\",\n directive(\"script-src\"),\n directive(\"style-src\"),\n directive(\"img-src\"),\n directive(\"font-src\"),\n directive(\"connect-src\"),\n directive(\"frame-src\"),\n // Frame ancestors: deny embedding (equivalent to X-Frame-Options: DENY)\n \"frame-ancestors 'none'\",\n // Form actions: only self\n \"form-action 'self'\",\n // Base URI: only self\n \"base-uri 'self'\",\n // Object sources: none (no plugins)\n \"object-src 'none'\",\n directive(\"worker-src\"),\n // Upgrade insecure requests in production.\n //\n // NOT under E2E_BUILD: the e2e harness runs a PRODUCTION build (for speed)\n // and serves it over plain http on a custom hostname. With this directive\n // the browser rewrites every /_next/static asset to https, which nothing is\n // listening on — every chunk fails with ERR_SSL_PROTOCOL_ERROR, the page\n // never hydrates, and each spec sees only server-rendered markup.\n env.NODE_ENV === \"production\" && env.E2E_BUILD !== \"true\" ? \"upgrade-insecure-requests\" : \"\",\n ]\n .filter(Boolean)\n .join(\"; \");\n}\n","// Runtime imports: relative only. See csp.ts.\nimport { buildCsp, type CspOptions } from \"./csp\";\n\nexport interface SecurityHeaderOptions extends CspOptions {\n permissionsPolicy?: { camera?: \"none\" | \"self\" };\n}\n\nexport interface HeaderRule {\n source: string;\n headers: { key: string; value: string }[];\n}\n\nexport function buildSecurityHeaders(options: SecurityHeaderOptions, env: NodeJS.ProcessEnv): HeaderRule[] {\n const camera = options.permissionsPolicy?.camera === \"none\" ? \"\" : \"self\";\n\n return [\n {\n // Apply to all routes\n source: \"/:path*\",\n headers: [\n // Content Security Policy - prevents XSS attacks\n {\n key: \"Content-Security-Policy\",\n value: buildCsp(options, env),\n },\n // X-Frame-Options - prevents clickjacking (backup for older browsers)\n {\n key: \"X-Frame-Options\",\n value: \"DENY\",\n },\n // X-Content-Type-Options - prevents MIME sniffing\n {\n key: \"X-Content-Type-Options\",\n value: \"nosniff\",\n },\n // Strict-Transport-Security - enforces HTTPS (1 year, include subdomains).\n // Omitted under E2E_BUILD for the same reason as upgrade-insecure-requests:\n // the suite speaks plain http, and an HSTS pin would force https on every\n // later request to this host from the same browser profile.\n ...(env.E2E_BUILD === \"true\"\n ? []\n : [\n {\n key: \"Strict-Transport-Security\",\n value: \"max-age=31536000; includeSubDomains\",\n },\n ]),\n // Referrer-Policy - controls referrer information\n {\n key: \"Referrer-Policy\",\n value: \"strict-origin-when-cross-origin\",\n },\n // Permissions-Policy - restricts browser features\n {\n key: \"Permissions-Policy\",\n value: `camera=(${camera}), microphone=(), geolocation=()`,\n },\n ],\n },\n ];\n}\n","// Shared next.config for the nja fleet. Apps wrap the result with their own\n// plugins: `withNextIntl(withMDX(createNextConfig({...})))`.\n//\n// next.config.js is loaded at RUNTIME by `next start`, and the production image\n// installs with --prod. This entry may therefore import only Node built-ins at\n// runtime: never next-intl, @next/mdx, serwist or any devDependency. `next` is\n// imported for its types only.\nimport type { NextConfig } from \"next\";\nimport { buildCsp, listAppOrigins, type CspDirective, type CspService } from \"./csp\";\nimport { buildSecurityHeaders } from \"./headers\";\n\nexport { buildCsp, listAppOrigins };\nexport type { CspDirective, CspService };\n\nexport interface NjaNextConfigOptions {\n devOrigins: string[];\n /** Third-party services the app loads; each adds its own hosts to the CSP. Nothing is allowed by default. */\n services?: CspService[];\n /** Extra CSP sources per directive, for hosts no named service covers. */\n csp?: Partial<Record<CspDirective, string[]>>;\n env?: Record<string, string>;\n imageMinimumCacheTTL?: number;\n permissionsPolicy?: { camera?: \"none\" | \"self\" };\n mdx?: boolean;\n serwist?: boolean;\n securityHeaders?: boolean;\n authInterrupts?: boolean;\n reactCompiler?: boolean;\n rewrites?: NextConfig[\"rewrites\"];\n extend?: (config: NextConfig) => NextConfig;\n}\n\ntype RemotePattern = { protocol: \"http\" | \"https\"; hostname: string; port?: string };\n\n// IMAGE_SOURCES, then the api and corpus origins (they serve every stored object\n// link), without duplicates.\nfunction parseImageSources(env: NodeJS.ProcessEnv): RemotePattern[] {\n const imageSources = env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(\",\").map((url) => url.trim()) : [];\n const patterns: RemotePattern[] = [];\n for (const url of [...imageSources, ...listAppOrigins(env)]) {\n const { hostname, protocol, port } = new URL(url);\n const pattern: RemotePattern = {\n protocol: protocol.replace(\":\", \"\") as RemotePattern[\"protocol\"],\n hostname,\n port: port || undefined,\n };\n const seen = patterns.some(\n (p) => p.protocol === pattern.protocol && p.hostname === pattern.hostname && p.port === pattern.port,\n );\n if (!seen) patterns.push(pattern);\n }\n return patterns;\n}\n\nexport function createNextConfig(options: NjaNextConfigOptions): NextConfig {\n const env = process.env;\n const reactCompiler = options.reactCompiler === true;\n\n const config: NextConfig = {\n // The e2e stack (scripts/e2e.sh) runs a PRODUCTION build of this app while a\n // `next dev` server may still be running against the same worktree. `next\n // build` and `next dev` share `.next`, so a build performed underneath a live\n // dev server clobbers it and that dev server then 404s every route. Giving\n // the e2e build its own directory keeps the two independent. E2E_BUILD must\n // be set for `next build` AND for `next start`, or `start` looks in `.next`\n // and serves someone else's output.\n ...(env.E2E_BUILD === \"true\" ? { distDir: \".next-e2e\" } : {}),\n cacheComponents: true,\n partialPrefetching: true,\n reactCompiler,\n experimental: {\n serverActions: {\n bodySizeLimit: \"5mb\",\n },\n // Required by `unauthorized()` (next/navigation): without this flag it throws a plain\n // Error instead of rendering the nearest unauthorized.tsx. The flag is for app code that\n // calls it; ServerSession.checkPermission does not, it still does `redirect(\"/401\")`.\n authInterrupts: options.authInterrupts ?? true,\n turbopackGc: true,\n // No turbopackLazyDynamicImports: in `next dev` it leaves this package's lazily\n // imported request module unactivated (\"Lazily compiled dynamic import of ...\n // JsonApiRequest ... was not activated\"), so every client-side read returns\n // nothing. Seen with the package installed from npm; production builds are unaffected.\n ...(reactCompiler ? { turbopackRustReactCompiler: true } : {}),\n },\n allowedDevOrigins: options.devOrigins,\n pageExtensions: options.mdx ? [\"ts\", \"tsx\", \"mdx\"] : [\"ts\", \"tsx\"],\n images: {\n minimumCacheTTL: options.imageMinimumCacheTTL ?? 60,\n remotePatterns: parseImageSources(env),\n dangerouslyAllowLocalIP: true,\n },\n poweredByHeader: false,\n reactStrictMode: false,\n env: {\n NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL ?? \"\",\n NEXT_PUBLIC_ADDRESS: env.NEXT_PUBLIC_ADDRESS ?? \"\",\n NEXT_PUBLIC_VAPID_PUBLIC_KEY: env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? \"\",\n NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? \"\",\n ...options.env,\n },\n // Inlined replacement for @serwist/turbopack's withSerwist wrapper, which\n // does nothing but append these two entries. Requiring the package here\n // instead CRASHES production: it is a devDependency, next.config.js is loaded\n // at RUNTIME by `next start`, and the web-production image installs with\n // --prod. Do not re-add a require of any devDependency to this file.\n ...(options.serwist ? { serverExternalPackages: [\"esbuild\", \"esbuild-wasm\"] } : {}),\n // Next 16 builds AND serves dev with Turbopack, so this is what pins yjs to a\n // single instance in both modes.\n turbopack: {\n resolveAlias: {\n yjs: \"./node_modules/yjs\",\n },\n },\n };\n\n if (options.securityHeaders !== false) {\n config.headers = async () => buildSecurityHeaders(options, process.env);\n }\n\n if (options.rewrites) config.rewrites = options.rewrites;\n\n return options.extend ? options.extend(config) : config;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/next-config/csp.ts","../../src/next-config/headers.ts","../../src/next-config/index.ts"],"sourcesContent":["// Runtime imports: none. next.config.js loads this at RUNTIME under `next start`\n// in an image installed with --prod, so it may import only Node built-ins.\n\nexport type CspDirective =\n \"script-src\" | \"style-src\" | \"img-src\" | \"font-src\" | \"connect-src\" | \"frame-src\" | \"worker-src\";\n\n/**\n * Third-party services an app may load. Each one adds its hosts to the\n * directives it needs; nothing is allowed unless the app names it.\n *\n * - `stripe`: the billing feature (`@carlonicora/nextjs-jsonapi/billing`).\n * - `googleMaps`: `FormPlaceAutocomplete` (Places API) and Maps JS/static tiles.\n * - `turnstile`: Cloudflare Turnstile widgets (contact and waitlist forms).\n * - `discord`: Discord avatars shown for users who log in with Discord.\n * - `googleFonts`: fonts linked at runtime from Google Fonts. Not needed for\n * `next/font/google`, which self-hosts at build time.\n */\nexport type CspService = \"stripe\" | \"googleMaps\" | \"turnstile\" | \"discord\" | \"googleFonts\";\n\nexport interface CspOptions {\n services?: CspService[];\n csp?: Partial<Record<CspDirective, string[]>>;\n}\n\nconst SERVICE_SOURCES: Record<CspService, Partial<Record<CspDirective, string[]>>> = {\n stripe: {\n \"script-src\": [\"https://js.stripe.com\"],\n \"img-src\": [\"https://*.stripe.com\"],\n \"connect-src\": [\"https://api.stripe.com\"],\n \"frame-src\": [\"https://js.stripe.com\", \"https://hooks.stripe.com\"],\n },\n googleMaps: {\n \"script-src\": [\"https://maps.googleapis.com\"],\n \"img-src\": [\"https://maps.googleapis.com\", \"https://maps.gstatic.com\"],\n \"connect-src\": [\"https://maps.googleapis.com\", \"https://places.googleapis.com\"],\n },\n turnstile: {\n \"script-src\": [\"https://challenges.cloudflare.com\"],\n \"connect-src\": [\"https://challenges.cloudflare.com\"],\n \"frame-src\": [\"https://challenges.cloudflare.com\"],\n },\n discord: {\n \"img-src\": [\"https://cdn.discordapp.com\"],\n },\n googleFonts: {\n \"style-src\": [\"https://fonts.googleapis.com\"],\n \"font-src\": [\"https://fonts.gstatic.com\"],\n },\n};\n\nfunction listImageSources(env: NodeJS.ProcessEnv): string[] {\n return env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(\",\").map((s) => s.trim()) : [];\n}\n\n/**\n * Origins of the app's own servers, the API and the corpus, from\n * `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_CORPUS_URL`. They serve every stored\n * object link, so images and frames must be allowed from them. Unset or invalid\n * URLs are skipped; the result has no duplicates.\n */\nexport function listAppOrigins(env: NodeJS.ProcessEnv): string[] {\n const origins: string[] = [];\n for (const url of [env.NEXT_PUBLIC_API_URL, env.NEXT_PUBLIC_CORPUS_URL]) {\n if (!url) continue;\n try {\n const { origin } = new URL(url.trim());\n if (origin !== \"null\" && !origins.includes(origin)) origins.push(origin);\n } catch {\n // Not a URL: nothing to allow.\n }\n }\n return origins;\n}\n\n// Build CSP directives\n// Note: 'unsafe-inline' and 'unsafe-eval' needed for Next.js dev mode and some runtime features\n// In production, consider using nonces for stricter CSP\nexport function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {\n const apiUrl = env.NEXT_PUBLIC_API_URL || \"\";\n const imageSources = listImageSources(env);\n const appOrigins = listAppOrigins(env);\n const services = options.services ?? [];\n\n // Each fetch directive: what Next itself needs, then the named services, then\n // the app's extras, then the sources derived from the env.\n const fetchDirectives: Record<CspDirective, { base: string[]; fromEnv: string[] }> = {\n // Scripts: self, and unsafe-inline/eval for Next.js\n \"script-src\": { base: [\"'self'\", \"'unsafe-inline'\", \"'unsafe-eval'\"], fromEnv: [] },\n // Styles: self and unsafe-inline for styled-components/emotion\n \"style-src\": { base: [\"'self'\", \"'unsafe-inline'\"], fromEnv: [] },\n // Images: self, data URIs, blobs, configured image sources, and the api/corpus origins\n \"img-src\": { base: [\"'self'\", \"data:\", \"blob:\"], fromEnv: [...imageSources, ...appOrigins] },\n // Fonts: self\n \"font-src\": { base: [\"'self'\"], fromEnv: [] },\n // Connect: self, API (http + ws for socket.io), and storage (for uploads)\n \"connect-src\": {\n base: [\"'self'\"],\n fromEnv: [\n apiUrl,\n // Add WebSocket URL for socket.io (convert http->ws, https->wss)\n apiUrl.replace(/^http/, \"ws\"),\n env.NEXT_PUBLIC_ADDRESS || \"\",\n ...imageSources,\n ],\n },\n // Frames: self, and the api/corpus origins (inline previews of stored objects)\n \"frame-src\": { base: [\"'self'\"], fromEnv: appOrigins },\n // Worker sources: self and blob (for Web Workers with dynamic imports)\n \"worker-src\": { base: [\"'self'\", \"blob:\"], fromEnv: [] },\n };\n\n const directive = (name: CspDirective): string => {\n const { base, fromEnv } = fetchDirectives[name];\n const fromServices = services.flatMap((service) => SERVICE_SOURCES[service][name] ?? []);\n const sources = [...base, ...fromServices, ...(options.csp?.[name] ?? []), ...fromEnv].filter(\n (source) => source !== \"\",\n );\n return [name, ...new Set(sources)].join(\" \");\n };\n\n return [\n \"default-src 'self'\",\n directive(\"script-src\"),\n directive(\"style-src\"),\n directive(\"img-src\"),\n directive(\"font-src\"),\n directive(\"connect-src\"),\n directive(\"frame-src\"),\n // Frame ancestors: deny embedding (equivalent to X-Frame-Options: DENY)\n \"frame-ancestors 'none'\",\n // Form actions: only self\n \"form-action 'self'\",\n // Base URI: only self\n \"base-uri 'self'\",\n // Object sources: none (no plugins)\n \"object-src 'none'\",\n directive(\"worker-src\"),\n // Upgrade insecure requests in production.\n //\n // NOT under E2E_BUILD: the e2e harness runs a PRODUCTION build (for speed)\n // and serves it over plain http on a custom hostname. With this directive\n // the browser rewrites every /_next/static asset to https, which nothing is\n // listening on — every chunk fails with ERR_SSL_PROTOCOL_ERROR, the page\n // never hydrates, and each spec sees only server-rendered markup.\n env.NODE_ENV === \"production\" && env.E2E_BUILD !== \"true\" ? \"upgrade-insecure-requests\" : \"\",\n ]\n .filter(Boolean)\n .join(\"; \");\n}\n","// Runtime imports: relative only. See csp.ts.\nimport { buildCsp, type CspOptions } from \"./csp\";\n\nexport interface SecurityHeaderOptions extends CspOptions {\n permissionsPolicy?: { camera?: \"none\" | \"self\" };\n}\n\nexport interface HeaderRule {\n source: string;\n headers: { key: string; value: string }[];\n}\n\nexport function buildSecurityHeaders(options: SecurityHeaderOptions, env: NodeJS.ProcessEnv): HeaderRule[] {\n const camera = options.permissionsPolicy?.camera === \"none\" ? \"\" : \"self\";\n\n return [\n {\n // Apply to all routes\n source: \"/:path*\",\n headers: [\n // Content Security Policy - prevents XSS attacks\n {\n key: \"Content-Security-Policy\",\n value: buildCsp(options, env),\n },\n // X-Frame-Options - prevents clickjacking (backup for older browsers)\n {\n key: \"X-Frame-Options\",\n value: \"DENY\",\n },\n // X-Content-Type-Options - prevents MIME sniffing\n {\n key: \"X-Content-Type-Options\",\n value: \"nosniff\",\n },\n // Strict-Transport-Security - enforces HTTPS (1 year, include subdomains).\n // Omitted under E2E_BUILD for the same reason as upgrade-insecure-requests:\n // the suite speaks plain http, and an HSTS pin would force https on every\n // later request to this host from the same browser profile.\n ...(env.E2E_BUILD === \"true\"\n ? []\n : [\n {\n key: \"Strict-Transport-Security\",\n value: \"max-age=31536000; includeSubDomains\",\n },\n ]),\n // Referrer-Policy - controls referrer information\n {\n key: \"Referrer-Policy\",\n value: \"strict-origin-when-cross-origin\",\n },\n // Permissions-Policy - restricts browser features\n {\n key: \"Permissions-Policy\",\n value: `camera=(${camera}), microphone=(), geolocation=()`,\n },\n ],\n },\n ];\n}\n","// Shared next.config for the nja fleet. Apps wrap the result with their own\n// plugins: `withNextIntl(withMDX(createNextConfig({...})))`.\n//\n// next.config.js is loaded at RUNTIME by `next start`, and the production image\n// installs with --prod. This entry may therefore import only Node built-ins at\n// runtime: never next-intl, @next/mdx or any devDependency. `next` is\n// imported for its types only.\nimport type { NextConfig } from \"next\";\nimport { buildCsp, listAppOrigins, type CspDirective, type CspService } from \"./csp\";\nimport { buildSecurityHeaders } from \"./headers\";\n\nexport { buildCsp, listAppOrigins };\nexport type { CspDirective, CspService };\n\nexport interface NjaNextConfigOptions {\n devOrigins: string[];\n /** Third-party services the app loads; each adds its own hosts to the CSP. Nothing is allowed by default. */\n services?: CspService[];\n /** Extra CSP sources per directive, for hosts no named service covers. */\n csp?: Partial<Record<CspDirective, string[]>>;\n env?: Record<string, string>;\n imageMinimumCacheTTL?: number;\n permissionsPolicy?: { camera?: \"none\" | \"self\" };\n mdx?: boolean;\n securityHeaders?: boolean;\n authInterrupts?: boolean;\n reactCompiler?: boolean;\n rewrites?: NextConfig[\"rewrites\"];\n extend?: (config: NextConfig) => NextConfig;\n}\n\ntype RemotePattern = { protocol: \"http\" | \"https\"; hostname: string; port?: string };\n\n// IMAGE_SOURCES, then the api and corpus origins (they serve every stored object\n// link), without duplicates.\nfunction parseImageSources(env: NodeJS.ProcessEnv): RemotePattern[] {\n const imageSources = env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(\",\").map((url) => url.trim()) : [];\n const patterns: RemotePattern[] = [];\n for (const url of [...imageSources, ...listAppOrigins(env)]) {\n const { hostname, protocol, port } = new URL(url);\n const pattern: RemotePattern = {\n protocol: protocol.replace(\":\", \"\") as RemotePattern[\"protocol\"],\n hostname,\n port: port || undefined,\n };\n const seen = patterns.some(\n (p) => p.protocol === pattern.protocol && p.hostname === pattern.hostname && p.port === pattern.port,\n );\n if (!seen) patterns.push(pattern);\n }\n return patterns;\n}\n\nexport function createNextConfig(options: NjaNextConfigOptions): NextConfig {\n const env = process.env;\n const reactCompiler = options.reactCompiler === true;\n\n const config: NextConfig = {\n // The e2e stack (scripts/e2e.sh) runs a PRODUCTION build of this app while a\n // `next dev` server may still be running against the same worktree. `next\n // build` and `next dev` share `.next`, so a build performed underneath a live\n // dev server clobbers it and that dev server then 404s every route. Giving\n // the e2e build its own directory keeps the two independent. E2E_BUILD must\n // be set for `next build` AND for `next start`, or `start` looks in `.next`\n // and serves someone else's output.\n ...(env.E2E_BUILD === \"true\" ? { distDir: \".next-e2e\" } : {}),\n cacheComponents: true,\n partialPrefetching: true,\n reactCompiler,\n experimental: {\n serverActions: {\n bodySizeLimit: \"5mb\",\n },\n // Required by `unauthorized()` (next/navigation): without this flag it throws a plain\n // Error instead of rendering the nearest unauthorized.tsx. The flag is for app code that\n // calls it; ServerSession.checkPermission does not, it still does `redirect(\"/401\")`.\n authInterrupts: options.authInterrupts ?? true,\n turbopackGc: true,\n // No turbopackLazyDynamicImports: in `next dev` it leaves this package's lazily\n // imported request module unactivated (\"Lazily compiled dynamic import of ...\n // JsonApiRequest ... was not activated\"), so every client-side read returns\n // nothing. Seen with the package installed from npm; production builds are unaffected.\n ...(reactCompiler ? { turbopackRustReactCompiler: true } : {}),\n },\n allowedDevOrigins: options.devOrigins,\n pageExtensions: options.mdx ? [\"ts\", \"tsx\", \"mdx\"] : [\"ts\", \"tsx\"],\n images: {\n minimumCacheTTL: options.imageMinimumCacheTTL ?? 60,\n remotePatterns: parseImageSources(env),\n dangerouslyAllowLocalIP: true,\n },\n poweredByHeader: false,\n reactStrictMode: false,\n env: {\n NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL ?? \"\",\n NEXT_PUBLIC_ADDRESS: env.NEXT_PUBLIC_ADDRESS ?? \"\",\n NEXT_PUBLIC_VAPID_PUBLIC_KEY: env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? \"\",\n NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? \"\",\n ...options.env,\n },\n // Next 16 builds AND serves dev with Turbopack, so this is what pins yjs to a\n // single instance in both modes.\n turbopack: {\n resolveAlias: {\n yjs: \"./node_modules/yjs\",\n },\n },\n };\n\n if (options.securityHeaders !== false) {\n config.headers = async () => buildSecurityHeaders(options, process.env);\n }\n\n if (options.rewrites) config.rewrites = options.rewrites;\n\n return options.extend ? options.extend(config) : config;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlonicora/nextjs-jsonapi",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Next.js JSON:API client with server/client support and caching",
|
|
5
5
|
"author": "Carlo Nicora",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -88,7 +88,8 @@
|
|
|
88
88
|
"types": "./dist/next-config/index.d.ts",
|
|
89
89
|
"import": "./dist/next-config/index.mjs",
|
|
90
90
|
"require": "./dist/next-config/index.js"
|
|
91
|
-
}
|
|
91
|
+
},
|
|
92
|
+
"./pwa/*": "./pwa/*"
|
|
92
93
|
},
|
|
93
94
|
"scripts": {
|
|
94
95
|
"build": "rm -rf dist && NODE_OPTIONS='--max-old-space-size=8192' tsup && tsc -p scripts/generate-web-module/tsconfig.json",
|
|
@@ -108,6 +109,7 @@
|
|
|
108
109
|
"dist",
|
|
109
110
|
"src",
|
|
110
111
|
"scripts",
|
|
112
|
+
"pwa",
|
|
111
113
|
"README.md"
|
|
112
114
|
],
|
|
113
115
|
"peerDependencies": {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!--
|
|
3
|
+
Offline fallback served by the service worker (sw.js) when a page navigation
|
|
4
|
+
fails. It is deliberately self-contained: inline styles, inline icons, no
|
|
5
|
+
build assets, no external script, stylesheet or font, because the build's
|
|
6
|
+
chunks are not available offline and change on every deploy.
|
|
7
|
+
|
|
8
|
+
This is a template. Each app copies it to public/offline.html and fills the
|
|
9
|
+
placeholders by hand: the app name, the theme blocks (one root block of the
|
|
10
|
+
six tokens, then one class block per non-light theme), the default-locale
|
|
11
|
+
strings, and the config object read by the script below.
|
|
12
|
+
-->
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<head>
|
|
15
|
+
<meta charset="utf-8" />
|
|
16
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
17
|
+
<title>{{APP_NAME}}</title>
|
|
18
|
+
<style>
|
|
19
|
+
/*{{THEME_CSS}}*/
|
|
20
|
+
* {
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
margin: 0;
|
|
23
|
+
}
|
|
24
|
+
body {
|
|
25
|
+
min-height: 100dvh;
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
padding: 1rem;
|
|
31
|
+
background: var(--background);
|
|
32
|
+
color: var(--foreground);
|
|
33
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
34
|
+
-webkit-font-smoothing: antialiased;
|
|
35
|
+
text-align: center;
|
|
36
|
+
}
|
|
37
|
+
.icon-circle {
|
|
38
|
+
width: 4rem;
|
|
39
|
+
height: 4rem;
|
|
40
|
+
margin: 0 auto 1.5rem;
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
border-radius: 9999px;
|
|
45
|
+
background: var(--muted);
|
|
46
|
+
color: var(--muted-foreground);
|
|
47
|
+
}
|
|
48
|
+
h1 {
|
|
49
|
+
margin-bottom: 0.5rem;
|
|
50
|
+
font-size: 1.5rem;
|
|
51
|
+
line-height: 2rem;
|
|
52
|
+
font-weight: 700;
|
|
53
|
+
}
|
|
54
|
+
p {
|
|
55
|
+
max-width: 28rem;
|
|
56
|
+
margin-bottom: 1.5rem;
|
|
57
|
+
color: var(--muted-foreground);
|
|
58
|
+
line-height: 1.5rem;
|
|
59
|
+
}
|
|
60
|
+
button {
|
|
61
|
+
display: inline-flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
gap: 0.5rem;
|
|
64
|
+
height: 1.75rem;
|
|
65
|
+
padding: 0 0.5rem;
|
|
66
|
+
border: 1px solid transparent;
|
|
67
|
+
border-radius: 0.375rem;
|
|
68
|
+
background: var(--primary);
|
|
69
|
+
color: var(--primary-foreground);
|
|
70
|
+
font: inherit;
|
|
71
|
+
font-size: 0.75rem;
|
|
72
|
+
font-weight: 500;
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
}
|
|
75
|
+
button:hover {
|
|
76
|
+
background: color-mix(in oklch, var(--primary) 80%, transparent);
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<div>
|
|
82
|
+
<div class="icon-circle">
|
|
83
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
84
|
+
<path d="M12 20h.01" />
|
|
85
|
+
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
|
|
86
|
+
<path d="M5 12.859a10 10 0 0 1 5.17-2.69" />
|
|
87
|
+
<path d="M19 12.859a10 10 0 0 0-2.007-1.523" />
|
|
88
|
+
<path d="M2 8.82a15 15 0 0 1 4.177-2.643" />
|
|
89
|
+
<path d="M22 8.82a15 15 0 0 0-11.288-3.764" />
|
|
90
|
+
<path d="m2 2 20 20" />
|
|
91
|
+
</svg>
|
|
92
|
+
</div>
|
|
93
|
+
<h1 id="title">{{TITLE}}</h1>
|
|
94
|
+
<p id="description">{{DESCRIPTION}}</p>
|
|
95
|
+
<button type="button" onclick="window.location.reload()">
|
|
96
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
97
|
+
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
|
98
|
+
<path d="M21 3v5h-5" />
|
|
99
|
+
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
|
100
|
+
<path d="M8 16H3v5" />
|
|
101
|
+
</svg>
|
|
102
|
+
<span id="retry">{{RETRY}}</span>
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
<script>
|
|
106
|
+
(function () {
|
|
107
|
+
var config = {{OFFLINE_CONFIG}};
|
|
108
|
+
var html = document.documentElement;
|
|
109
|
+
var theme = null;
|
|
110
|
+
try { theme = localStorage.getItem("theme"); } catch (e) { theme = null; }
|
|
111
|
+
theme = theme || config.defaultTheme;
|
|
112
|
+
if (theme === "system") {
|
|
113
|
+
theme = config.enableSystem
|
|
114
|
+
? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
|
115
|
+
: config.defaultTheme;
|
|
116
|
+
}
|
|
117
|
+
if (theme !== "light" && config.themes.indexOf(theme) !== -1) html.classList.add(theme);
|
|
118
|
+
var fromPath = location.pathname.split("/")[1];
|
|
119
|
+
var cookieMatch = document.cookie.match(/(?:^|; )NEXT_LOCALE=([^;]*)/);
|
|
120
|
+
var fromCookie = cookieMatch ? decodeURIComponent(cookieMatch[1]) : null;
|
|
121
|
+
var fromBrowser = (navigator.languages || []).map(function (l) { return l.toLowerCase().split("-")[0]; });
|
|
122
|
+
var candidates = [fromPath, fromCookie].concat(fromBrowser);
|
|
123
|
+
var locale = config.defaultLocale;
|
|
124
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
125
|
+
if (candidates[i] && config.locales.indexOf(candidates[i]) !== -1) { locale = candidates[i]; break; }
|
|
126
|
+
}
|
|
127
|
+
html.lang = locale;
|
|
128
|
+
html.dir = config.rtl.indexOf(locale) !== -1 ? "rtl" : "ltr";
|
|
129
|
+
var strings = config.strings[locale];
|
|
130
|
+
if (strings) {
|
|
131
|
+
document.getElementById("title").textContent = strings.title;
|
|
132
|
+
document.getElementById("description").textContent = strings.description;
|
|
133
|
+
document.getElementById("retry").textContent = strings.retry;
|
|
134
|
+
}
|
|
135
|
+
})();
|
|
136
|
+
</script>
|
|
137
|
+
</body>
|
|
138
|
+
</html>
|
package/pwa/sw.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Standard service worker for every nja app.
|
|
2
|
+
//
|
|
3
|
+
// This file is copied verbatim into every app's public/sw.js. Each app's guard
|
|
4
|
+
// spec compares the bytes of public/sw.js against this file, so never edit an
|
|
5
|
+
// app's copy: change this file, release the library, and copy it again.
|
|
6
|
+
//
|
|
7
|
+
// Per-app values (appId, offlineRevision, notificationIcon) come from the app's
|
|
8
|
+
// own public/sw-config.js, which sets self.NJA_SW_CONFIG.
|
|
9
|
+
|
|
10
|
+
importScripts("/sw-config.js");
|
|
11
|
+
|
|
12
|
+
const OFFLINE_URL = "/offline.html";
|
|
13
|
+
const CACHE_NAME = `${self.NJA_SW_CONFIG.appId}-offline-${self.NJA_SW_CONFIG.offlineRevision}`;
|
|
14
|
+
|
|
15
|
+
// Served when a navigation fails and the cached offline page is missing too
|
|
16
|
+
// (evicted or cleared). Never answer a navigation with a bare network error.
|
|
17
|
+
const OFFLINE_FALLBACK_HTML = `<!doctype html>
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
22
|
+
<title>You are offline</title>
|
|
23
|
+
<style>
|
|
24
|
+
body {
|
|
25
|
+
margin: 0;
|
|
26
|
+
min-height: 100vh;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
31
|
+
background: #ffffff;
|
|
32
|
+
color: #111111;
|
|
33
|
+
text-align: center;
|
|
34
|
+
padding: 16px;
|
|
35
|
+
}
|
|
36
|
+
@media (prefers-color-scheme: dark) {
|
|
37
|
+
body {
|
|
38
|
+
background: #111111;
|
|
39
|
+
color: #f5f5f5;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
button {
|
|
43
|
+
margin-top: 16px;
|
|
44
|
+
padding: 8px 16px;
|
|
45
|
+
font: inherit;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<main>
|
|
52
|
+
<h1>You are offline</h1>
|
|
53
|
+
<p>Check your connection and try again.</p>
|
|
54
|
+
<button type="button" onclick="location.reload()">Retry</button>
|
|
55
|
+
</main>
|
|
56
|
+
</body>
|
|
57
|
+
</html>`;
|
|
58
|
+
|
|
59
|
+
self.addEventListener("install", function (event) {
|
|
60
|
+
event.waitUntil(
|
|
61
|
+
caches.open(CACHE_NAME).then(function (cache) {
|
|
62
|
+
// cache.add rejects on a network error or a non-2xx response, so the
|
|
63
|
+
// install fails and the previous worker stays in charge.
|
|
64
|
+
return cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
self.addEventListener("activate", function (event) {
|
|
70
|
+
event.waitUntil(
|
|
71
|
+
(async function () {
|
|
72
|
+
const keys = await caches.keys();
|
|
73
|
+
await Promise.all(
|
|
74
|
+
keys
|
|
75
|
+
.filter(function (key) {
|
|
76
|
+
return key !== CACHE_NAME;
|
|
77
|
+
})
|
|
78
|
+
.map(function (key) {
|
|
79
|
+
return caches.delete(key);
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
if (self.registration.navigationPreload) {
|
|
83
|
+
await self.registration.navigationPreload.enable();
|
|
84
|
+
}
|
|
85
|
+
await self.clients.claim();
|
|
86
|
+
})(),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
self.addEventListener("fetch", function (event) {
|
|
91
|
+
// Only navigations are intercepted. Everything else goes straight to the
|
|
92
|
+
// network without respondWith.
|
|
93
|
+
if (event.request.mode !== "navigate") return;
|
|
94
|
+
|
|
95
|
+
event.respondWith(
|
|
96
|
+
(async function () {
|
|
97
|
+
try {
|
|
98
|
+
const preload = await event.preloadResponse;
|
|
99
|
+
if (preload) return preload;
|
|
100
|
+
// A 4xx/5xx or an opaque redirect is passed through as is.
|
|
101
|
+
return await fetch(event.request);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const cache = await caches.open(CACHE_NAME);
|
|
104
|
+
const cached = await cache.match(OFFLINE_URL);
|
|
105
|
+
if (cached) return cached;
|
|
106
|
+
return new Response(OFFLINE_FALLBACK_HTML, {
|
|
107
|
+
status: 200,
|
|
108
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
})(),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
self.addEventListener("message", function (event) {
|
|
116
|
+
if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
self.addEventListener("push", function (event) {
|
|
120
|
+
const data = event.data ? event.data.json() : {};
|
|
121
|
+
const title = data.title || "Notification";
|
|
122
|
+
const options = {
|
|
123
|
+
body: data.message || "You have a new notification",
|
|
124
|
+
icon: self.NJA_SW_CONFIG.notificationIcon,
|
|
125
|
+
data: data.url,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
event.waitUntil(self.registration.showNotification(title, options));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
self.addEventListener("notificationclick", function (event) {
|
|
132
|
+
event.notification.close();
|
|
133
|
+
event.waitUntil(
|
|
134
|
+
(async function () {
|
|
135
|
+
const clientList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
136
|
+
for (const windowClient of clientList) {
|
|
137
|
+
if (windowClient.url === event.notification.data && "focus" in windowClient) {
|
|
138
|
+
return windowClient.focus();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (self.clients.openWindow && event.notification.data) {
|
|
142
|
+
return self.clients.openWindow(event.notification.data);
|
|
143
|
+
}
|
|
144
|
+
})(),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// FCM rotates/expires subscriptions periodically. When it does, re-subscribe
|
|
149
|
+
// with the old application server key and hand the new subscription to ONE open
|
|
150
|
+
// client (a visible one if there is one), which re-registers it against the API
|
|
151
|
+
// with the user's auth. If no client is open, the next page load's
|
|
152
|
+
// usePushNotifications re-registers.
|
|
153
|
+
self.addEventListener("pushsubscriptionchange", function (event) {
|
|
154
|
+
event.waitUntil(
|
|
155
|
+
(async function () {
|
|
156
|
+
const applicationServerKey = event.oldSubscription && event.oldSubscription.options.applicationServerKey;
|
|
157
|
+
if (!applicationServerKey) return;
|
|
158
|
+
|
|
159
|
+
const subscription = await self.registration.pushManager.subscribe({
|
|
160
|
+
userVisibleOnly: true,
|
|
161
|
+
applicationServerKey,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const plainSubscription = { endpoint: subscription.endpoint, keys: subscription.toJSON().keys };
|
|
165
|
+
const clientList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
166
|
+
const target =
|
|
167
|
+
clientList.find(function (windowClient) {
|
|
168
|
+
return windowClient.visibilityState === "visible";
|
|
169
|
+
}) || clientList[0];
|
|
170
|
+
if (target) {
|
|
171
|
+
target.postMessage({ type: "PUSH_RESUBSCRIBED", subscription: plainSubscription });
|
|
172
|
+
}
|
|
173
|
+
})(),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { ReactNode, type JSX } from "react";
|
|
4
|
+
import { useCurrentUserContext } from "../../../../contexts";
|
|
4
5
|
import usePushNotifications from "../../../../hooks/usePushNotifications";
|
|
6
|
+
import { getRoleId } from "../../../../roles";
|
|
7
|
+
import { UserInterface } from "../../../user/data";
|
|
5
8
|
|
|
6
9
|
export function PushNotificationProvider({ children }: { children: ReactNode }): JSX.Element {
|
|
7
|
-
|
|
10
|
+
const { currentUser, hasRole } = useCurrentUserContext<UserInterface>();
|
|
11
|
+
usePushNotifications({ currentUser: currentUser ?? undefined, canSubscribe: !hasRole(getRoleId().Administrator) });
|
|
8
12
|
return <>{children}</>;
|
|
9
13
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { PushNotificationProvider } from "../PushNotificationProvider";
|
|
4
|
+
|
|
5
|
+
// Hoisted so the vi.mock factories below can read it (Vitest hoists factories
|
|
6
|
+
// above module-scope consts).
|
|
7
|
+
const mocks = vi.hoisted(() => ({
|
|
8
|
+
hook: vi.fn(),
|
|
9
|
+
context: {
|
|
10
|
+
currentUser: { id: "u1" } as { id: string } | undefined,
|
|
11
|
+
hasRole: (() => false) as (roleId: string) => boolean,
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("../../../../../hooks/usePushNotifications", () => ({ default: mocks.hook }));
|
|
16
|
+
|
|
17
|
+
vi.mock("../../../../../contexts", () => ({
|
|
18
|
+
useCurrentUserContext: () => mocks.context,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("../../../../../roles", () => ({
|
|
22
|
+
getRoleId: () => ({ Administrator: "administrator" }),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe("PushNotificationProvider", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
mocks.hook.mockReset();
|
|
28
|
+
mocks.context.currentUser = { id: "u1" };
|
|
29
|
+
mocks.context.hasRole = () => false;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("passes the current user and canSubscribe=true for a non-administrator", () => {
|
|
33
|
+
render(<PushNotificationProvider>child</PushNotificationProvider>);
|
|
34
|
+
expect(mocks.hook).toHaveBeenCalledWith({ currentUser: { id: "u1" }, canSubscribe: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("passes canSubscribe=false for an administrator", () => {
|
|
38
|
+
mocks.context.hasRole = (roleId: string) => roleId === "administrator";
|
|
39
|
+
render(<PushNotificationProvider>child</PushNotificationProvider>);
|
|
40
|
+
expect(mocks.hook).toHaveBeenCalledWith({ currentUser: { id: "u1" }, canSubscribe: false });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders its children", () => {
|
|
44
|
+
render(
|
|
45
|
+
<PushNotificationProvider>
|
|
46
|
+
<span>inside the provider</span>
|
|
47
|
+
</PushNotificationProvider>,
|
|
48
|
+
);
|
|
49
|
+
expect(screen.getByText("inside the provider")).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,53 +1,56 @@
|
|
|
1
|
-
import { render, waitFor } from "@testing-library/react";
|
|
1
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { UserInterface } from "../../features/user/data";
|
|
3
4
|
import usePushNotifications from "../usePushNotifications";
|
|
4
5
|
|
|
5
6
|
// Hoisted so the vi.mock factories below can read it (Vitest hoists factories
|
|
6
7
|
// above module-scope consts).
|
|
7
|
-
const
|
|
8
|
+
const mocks = vi.hoisted(() => ({ register: vi.fn() }));
|
|
8
9
|
|
|
9
10
|
vi.mock("../../core/env", () => ({
|
|
10
11
|
ENV: {
|
|
11
|
-
get IS_PRODUCTION() {
|
|
12
|
-
return state.isProduction;
|
|
13
|
-
},
|
|
14
12
|
VAPID_PUBLIC_KEY: "",
|
|
15
13
|
},
|
|
16
14
|
}));
|
|
17
15
|
|
|
18
|
-
vi.mock("../../contexts", () => ({
|
|
19
|
-
useCurrentUserContext: () => ({
|
|
20
|
-
currentUser: { id: "user-1" },
|
|
21
|
-
hasRole: () => false,
|
|
22
|
-
}),
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
16
|
vi.mock("../../features/push/data/push.service", () => ({
|
|
26
|
-
PushService: { register:
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
vi.mock("../../roles", () => ({
|
|
30
|
-
getRoleId: () => ({ Administrator: "administrator" }),
|
|
17
|
+
PushService: { register: mocks.register },
|
|
31
18
|
}));
|
|
32
19
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
20
|
+
// The hook reads only `id` from the user; the fixture is typed as the real
|
|
21
|
+
// input so a signature change fails here first.
|
|
22
|
+
const user = { id: "user-1" } as UserInterface;
|
|
36
23
|
|
|
37
|
-
function Probe() {
|
|
38
|
-
usePushNotifications();
|
|
24
|
+
function Probe(props: Parameters<typeof usePushNotifications>[0]) {
|
|
25
|
+
usePushNotifications(props);
|
|
39
26
|
return null;
|
|
40
27
|
}
|
|
41
28
|
|
|
42
|
-
|
|
29
|
+
const subscription = { endpoint: "e1", keys: { p256dh: "a", auth: "b" } };
|
|
30
|
+
|
|
31
|
+
describe("usePushNotifications", () => {
|
|
43
32
|
const register = vi.fn().mockResolvedValue({ pushManager: { getSubscription: vi.fn() } });
|
|
33
|
+
const addEventListener = vi.fn();
|
|
34
|
+
const removeEventListener = vi.fn();
|
|
35
|
+
|
|
36
|
+
function messageListener(): (event: MessageEvent) => void {
|
|
37
|
+
const call = addEventListener.mock.calls.find(([type]) => type === "message");
|
|
38
|
+
if (!call) throw new Error("no message listener registered");
|
|
39
|
+
return call[1];
|
|
40
|
+
}
|
|
44
41
|
|
|
45
42
|
beforeEach(() => {
|
|
46
43
|
register.mockClear();
|
|
47
|
-
|
|
44
|
+
addEventListener.mockClear();
|
|
45
|
+
removeEventListener.mockClear();
|
|
46
|
+
mocks.register.mockReset();
|
|
47
|
+
mocks.register.mockResolvedValue(undefined);
|
|
48
|
+
vi.stubGlobal("navigator", {
|
|
49
|
+
serviceWorker: { register, ready: Promise.resolve(), addEventListener, removeEventListener },
|
|
50
|
+
});
|
|
48
51
|
vi.stubGlobal("PushManager", function PushManager() {});
|
|
49
|
-
// "denied" makes the
|
|
50
|
-
//
|
|
52
|
+
// "denied" makes the registration effect return right after registering, so
|
|
53
|
+
// these specs prove the URL choice and the rotation flow, not subscription handling.
|
|
51
54
|
vi.stubGlobal("Notification", { permission: "denied", requestPermission: vi.fn() });
|
|
52
55
|
sessionStorage.clear();
|
|
53
56
|
});
|
|
@@ -56,15 +59,65 @@ describe("usePushNotifications worker URL", () => {
|
|
|
56
59
|
vi.unstubAllGlobals();
|
|
57
60
|
});
|
|
58
61
|
|
|
59
|
-
it("registers
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
it("registers /sw.js with updateViaCache none for a user who can subscribe", async () => {
|
|
63
|
+
render(<Probe currentUser={user} canSubscribe={true} />);
|
|
64
|
+
await waitFor(() => expect(register).toHaveBeenCalledWith("/sw.js", { updateViaCache: "none" }));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not register without a user", async () => {
|
|
68
|
+
render(<Probe canSubscribe={true} />);
|
|
69
|
+
await act(async () => {});
|
|
70
|
+
expect(register).not.toHaveBeenCalled();
|
|
71
|
+
expect(addEventListener).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not register when the user cannot subscribe", async () => {
|
|
75
|
+
render(<Probe currentUser={user} canSubscribe={false} />);
|
|
76
|
+
await act(async () => {});
|
|
77
|
+
expect(register).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("re-registers a rotated subscription once per tab", async () => {
|
|
81
|
+
render(<Probe currentUser={user} canSubscribe={true} />);
|
|
82
|
+
const onMessage = messageListener();
|
|
83
|
+
const event = { data: { type: "PUSH_RESUBSCRIBED", subscription } } as MessageEvent;
|
|
84
|
+
|
|
85
|
+
act(() => onMessage(event));
|
|
86
|
+
await waitFor(() =>
|
|
87
|
+
expect(sessionStorage.getItem("push_registered_user-1")).toBe(btoa(JSON.stringify(subscription))),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
act(() => onMessage(event));
|
|
91
|
+
await act(async () => {});
|
|
92
|
+
|
|
93
|
+
expect(mocks.register).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(mocks.register).toHaveBeenCalledWith({ data: subscription });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("ignores other message types and a PUSH_RESUBSCRIBED without a subscription", async () => {
|
|
98
|
+
render(<Probe currentUser={user} canSubscribe={true} />);
|
|
99
|
+
const onMessage = messageListener();
|
|
100
|
+
|
|
101
|
+
act(() => onMessage({ data: { type: "SOMETHING_ELSE", subscription } } as MessageEvent));
|
|
102
|
+
act(() => onMessage({ data: { type: "PUSH_RESUBSCRIBED" } } as MessageEvent));
|
|
103
|
+
act(() => onMessage({ data: undefined } as MessageEvent));
|
|
104
|
+
await act(async () => {});
|
|
105
|
+
|
|
106
|
+
expect(mocks.register).not.toHaveBeenCalled();
|
|
107
|
+
expect(sessionStorage.getItem("push_registered_user-1")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not re-register a rotation when the user cannot subscribe", async () => {
|
|
111
|
+
render(<Probe currentUser={user} canSubscribe={false} />);
|
|
112
|
+
await act(async () => {});
|
|
113
|
+
expect(addEventListener).not.toHaveBeenCalledWith("message", expect.any(Function));
|
|
114
|
+
expect(mocks.register).not.toHaveBeenCalled();
|
|
63
115
|
});
|
|
64
116
|
|
|
65
|
-
it("
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
117
|
+
it("removes the message listener on unmount", () => {
|
|
118
|
+
const { unmount } = render(<Probe currentUser={user} canSubscribe={true} />);
|
|
119
|
+
const onMessage = messageListener();
|
|
120
|
+
unmount();
|
|
121
|
+
expect(removeEventListener).toHaveBeenCalledWith("message", onMessage);
|
|
69
122
|
});
|
|
70
123
|
});
|