From c3f63c3e960f2dba81063dda1788bf2aa674563f Mon Sep 17 00:00:00 2001 From: oche11207-art Date: Fri, 19 Jun 2026 23:02:38 +0000 Subject: [PATCH] feat: implement PWA offline caching strategy (#20) - Create service worker (public/sw.js) with utility-v1 cache - Cache-first strategy for static assets (stale-while-revalidate) - Network-first strategy for API calls with cache fallback - Network-first navigation with offline HTML fallback - Pre-cache app shell assets on install - Create ServiceWorkerProvider client component for SW registration - Update layout.tsx to include ServiceWorkerProvider - Generate placeholder PWA icons (192x192 and 512x512) - Add unit tests for ServiceWorkerProvider (6 tests) - Add icon generation script Closes #20 --- package-lock.json | 287 ++++++++++-------- public/icon-192.png | Bin 0 -> 1620 bytes public/icon-512.png | Bin 0 -> 6030 bytes public/sw.js | 176 +++++++++++ scripts/generate-icons.js | 224 ++++++++++++++ src/app/layout.tsx | 9 +- .../providers/ServiceWorkerProvider.tsx | 43 +++ .../components/ServiceWorkerProvider.test.tsx | 198 ++++++++++++ 8 files changed, 801 insertions(+), 136 deletions(-) create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.js create mode 100644 src/components/providers/ServiceWorkerProvider.tsx create mode 100644 tests/components/ServiceWorkerProvider.test.tsx diff --git a/package-lock.json b/package-lock.json index 91bc650..7dbc63a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -414,9 +414,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz", - "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz", + "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==", "dev": true, "funding": [ { @@ -1535,13 +1535,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.60.0" + "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -1853,6 +1853,7 @@ "version": "15.0.0", "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-15.0.0.tgz", "integrity": "sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==", + "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", @@ -2208,17 +2209,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2380,17 +2370,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", - "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/type-utils": "8.61.0", - "@typescript-eslint/utils": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2403,7 +2393,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2419,16 +2409,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", - "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "engines": { @@ -2444,14 +2434,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", - "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.0", - "@typescript-eslint/types": "^8.61.0", + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "engines": { @@ -2466,14 +2456,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", - "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2484,9 +2474,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", - "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -2501,15 +2491,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", - "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2526,9 +2516,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", - "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -2540,16 +2530,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", - "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.0", - "@typescript-eslint/tsconfig-utils": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2607,9 +2597,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -2620,16 +2610,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", - "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2644,13 +2634,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", - "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3209,13 +3199,13 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" + "dependencies": { + "dequal": "^2.0.3" } }, "node_modules/array-buffer-byte-length": { @@ -3494,9 +3484,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -3516,9 +3506,9 @@ } }, "node_modules/bignumber.js": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.3.tgz", - "integrity": "sha512-+esZiNSo6VgFokTsYX6mYqNJfFd/IczzZCd4Z7cR8e+AQWhvIcj6nqQ1h9814D9u/TApU0jjTVmfWL0Pd1ZBdA==", + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.4.tgz", + "integrity": "sha512-AJ9dSeaUGj2xu7tEwmdqb51dqdb633xo4njI9K8ZFfcLrNr0XN8/EPkkZUNaF9fkCblGt2zVwZymesUdGynEkQ==", "license": "MIT" }, "node_modules/brace-expansion": { @@ -3995,7 +3985,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -4052,9 +4041,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.372", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", - "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "dev": true, "license": "ISC" }, @@ -4161,6 +4150,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4255,15 +4263,17 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.1.tgz", + "integrity": "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==", "dev": true, "license": "MIT", "dependencies": { + "es-abstract-get": "^1.0.0", + "es-errors": "^1.3.0", "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -4554,6 +4564,16 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -4919,6 +4939,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5427,9 +5448,9 @@ } }, "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -6461,9 +6482,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", "funding": [ { "type": "github", @@ -6602,9 +6623,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "dev": true, "license": "MIT", "engines": { @@ -6904,13 +6925,13 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -6923,9 +6944,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -7022,14 +7043,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7042,6 +7055,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -7130,11 +7150,12 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redent": { "version": "3.0.0", @@ -7556,9 +7577,9 @@ } }, "node_modules/sharp/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -8281,16 +8302,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", - "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.0", - "@typescript-eslint/parser": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0" + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..11a851653ffb4be767568e67a6f98f33b0cc8ba4 GIT binary patch literal 1620 zcmaJ?eKeF=7{Bk^JG?W*X004skkuZ!zVs7C&%d8oqz+eI;&M<3P2QMz(q7#%o@bU3P<$8^)v6>h_u( zF%{cp9^<4aQHE%qKfHC3s>)u~`Jsn89n4~?8B@|FgJQ+y86<9YU&C1a7$?o6%U5HY z{Pl5HI6T!@e0|e_&erC(EAc&)`jl7k>RqV!i85th$a#RdR*}z^Wqy z1ZFHTZ^4t;Ff1B9K|=~@t89VM?*d?RKG?hkB(Wf4BUhJ|K7-4JMWEUjRJ96u#^^HR zrLdMRsI8|VN95|aniRmXg5S^yiyb3q4-5YK1mjUqrCl%xXF<-mkmrHATZMpY99V(+ z@mx^%<)Ofr1D&n2qav_`Od+Cw6x?Rmrn8NRjaC$%HR>MpWd;y-tA2Uw1y9lm39edm zas<7`h7B3CsAX8frO5cS{9=Mp zr4Kllp~o^tpR*&e9dv==AE@Xs*e^BpreWPd%^-{gw?47MSm{f8%O{Z^v*-9C9TZl2 z>&xybu)cCgO9_=Y?6)Mcah4&v-JyA0>lZ5VBpo|(mMQ7j0!SKz`r6Arrmc!YDt*B@ z;o{Vf*IIujhi`*!XZMZvSV5Bl1HJ?KBBeH*10SYS^30I?Cid_?F0bC=bf|{|PbE?E zERg$!cJK`@FH+uAn-T%NwUbC!_LwXU?35Z4bX=afHY7FE8?3A}o8{rExN?gRSw0|E zZ{Se|EqqKTPJWV;>W(E8iDWarR6Hk*g5?DNru45XBO zSDWPrVnqvxdR(P1ZRgvQ1@5kx?Z#DS1!_w(#617m-BbydAUr*0CBD~ zz>p_>OybTXU4v%tyIzynsLj;?vLP?r@k9;=ClnC777Ty22edFsmyL22olB8}feGh{ zvNp#OY&225FP*6~!<|^z6vrU4jy8cLqXWmto>qNm|IjYOxbxfQZ<8rHU6JPgE03Kyb%U`^$JB%DJ^4@8#Dpk*C~>>g%ZIVL z%vYWM=e$<58^!Py6;pPuy^hdM8ZzYN^*t2JeW{9K<>wl1^4{U0Q&_^c+LYa?YY!J6 z`Fp&+Yizn1)Ohky0d%vqxCnSml(>h3AP%t40rf8bGzvBE_*+iT9@n8}-ywes5a1Uq J{K+RO{ZI40o$>$x literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..c73c1603e447fe49a9b68dde88c0a3ec16fade70 GIT binary patch literal 6030 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 { + event.waitUntil( + caches.open(STATIC_CACHE).then((cache) => { + return cache.addAll(PRE_CACHE_ASSETS).catch((err) => { + console.warn("[SW] Pre-cache partial failure:", err.message); + }); + }) + ); + // Activate immediately — don't wait for old tabs to close + self.skipWaiting(); +}); + +/* ---------- Activate: purge stale caches ---------- */ +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => { + const valid = [STATIC_CACHE, API_CACHE, HTML_CACHE]; + return Promise.all( + keys.filter((k) => !valid.includes(k)).map((k) => caches.delete(k)) + ); + }) + ); + // Claim all clients so the new SW controls pages immediately + self.clients.claim(); +}); + +/* ---------- Helper: is the request for an API endpoint? ---------- */ +function isApiRequest(url) { + try { + const { pathname } = new URL(url); + return ( + pathname.startsWith("/api/") || + pathname.includes("/rpc") || + pathname.includes("/graphql") + ); + } catch { + return false; + } +} + +/* ---------- Helper: is the request for a static asset? ---------- */ +function isStaticAsset(url) { + try { + const { pathname } = new URL(url); + return ( + /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|webp|avif)$/i.test( + pathname + ) || + pathname.startsWith("/_next/static/") || + pathname.startsWith("/_next/image/") || + pathname.startsWith("/icon-") || + pathname === "/manifest.json" + ); + } catch { + return false; + } +} + +/* ---------- Helper: is the request a navigation (HTML) request? ---------- */ +function isNavigationRequest(request) { + return request.mode === "navigate"; +} + +/* ---------- Strategy: Network-first (for API calls) ---------- */ +async function networkFirst(request) { + const cache = await caches.open(API_CACHE); + try { + const response = await fetch(request); + // Only cache successful responses + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (err) { + const cached = await cache.match(request); + if (cached) { + return cached; + } + throw err; + } +} + +/* ---------- Strategy: Cache-first (for static assets) ---------- */ +async function cacheFirst(request) { + const cache = await caches.open(STATIC_CACHE); + const cached = await cache.match(request); + if (cached) { + // Update cache in background (stale-while-revalidate) + fetch(request) + .then((response) => { + if (response.ok) { + cache.put(request, response); + } + }) + .catch(() => { + /* ignore background update failures */ + }); + return cached; + } + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (err) { + throw err; + } +} + +/* ---------- Strategy: Network-first with HTML cache fallback ---------- */ +async function htmlNetworkFirst(request) { + const cache = await caches.open(HTML_CACHE); + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (err) { + const cached = await cache.match(request); + if (cached) { + return cached; + } + // Final fallback: return the cached root page + const fallback = await cache.match("/"); + if (fallback) { + return fallback; + } + throw err; + } +} + +/* ---------- Fetch event: route to appropriate strategy ---------- */ +self.addEventListener("fetch", (event) => { + const { request } = event; + + // Skip non-GET requests + if (request.method !== "GET") { + return; + } + + // Skip chrome-extension and other non-http(s) URLs + const url = request.url; + if (!url.startsWith("http")) { + return; + } + + // Route based on request type + if (isNavigationRequest(request)) { + event.respondWith(htmlNetworkFirst(request)); + } else if (isApiRequest(url)) { + event.respondWith(networkFirst(request)); + } else if (isStaticAsset(url)) { + event.respondWith(cacheFirst(request)); + } + // Everything else: let the browser handle it normally +}); diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js new file mode 100644 index 0000000..6ba742f --- /dev/null +++ b/scripts/generate-icons.js @@ -0,0 +1,224 @@ +/* eslint-disable */ +/** + * Generates placeholder PWA icons for Utility Protocol. + * Creates simple but recognizable utility/energy-themed icons + * using only Node.js built-ins (zlib for PNG compression). + * + * Design: Dark background (#0a0a0a) with a stylized lightning-bolt + * motif in a teal accent color. + */ + +const zlib = require("zlib"); +const fs = require("fs"); +const path = require("path"); + +const BG = [0x0a, 0x0a, 0x0a, 0xff]; // #0a0a0a +const ACCENT = [0x00, 0xd4, 0xaa, 0xff]; // teal/green accent + +function createCRC(buf) { + // CRC-32 for PNG + let crc = 0xffffffff; + const table = new Int32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c; + } + for (let i = 0; i < buf.length; i++) { + crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createChunk(type, data) { + const typeBytes = Buffer.from(type, "ascii"); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(data.length, 0); + const crcInput = Buffer.concat([typeBytes, data]); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(createCRC(crcInput), 0); + return Buffer.concat([lenBuf, typeBytes, data, crcBuf]); +} + +function createPNG(width, height, pixels) { + const signature = Buffer.from([ + 137, 80, 78, 71, 13, 10, 26, 10, + ]); + + // IHDR + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(width, 0); + ihdrData.writeUInt32BE(height, 4); + ihdrData[8] = 8; // bit depth + ihdrData[9] = 6; // color type: RGBA + ihdrData[10] = 0; // compression + ihdrData[11] = 0; // filter + ihdrData[12] = 0; // interlace + const ihdr = createChunk("IHDR", ihdrData); + + // Raw image data with filter byte per row + const rawData = Buffer.alloc(height * (1 + width * 4)); + for (let y = 0; y < height; y++) { + const rowOffset = y * (1 + width * 4); + rawData[rowOffset] = 0; // filter: none + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const dst = rowOffset + 1 + x * 4; + rawData[dst] = pixels[idx]; + rawData[dst + 1] = pixels[idx + 1]; + rawData[dst + 2] = pixels[idx + 2]; + rawData[dst + 3] = pixels[idx + 3]; + } + } + + const compressed = zlib.deflateSync(rawData); + const idat = createChunk("IDAT", compressed); + const iend = createChunk("IEND", Buffer.alloc(0)); + + return Buffer.concat([signature, ihdr, idat, iend]); +} + +/** + * Draw a simple utility/energy icon: + * - Dark rounded square background + * - Stylized lightning-bolt shape in teal + */ +function drawIcon(size) { + const pixels = Buffer.alloc(size * size * 4); + const cx = size / 2; + const cy = size / 2; + const outerR = size * 0.44; + + // Corner radius for rounded rect + const cornerR = size * 0.12; + + function distToRectEdge(px, py) { + const half = outerR; + const left = cx - half; + const right = cx + half; + const top = cy - half; + const bottom = cy + half; + + // Inside rect + if (px >= left + cornerR && px <= right - cornerR && py >= top && py <= bottom) return -1; + if (py >= top + cornerR && py <= bottom - cornerR && px >= left && px <= right) return -1; + + // Distance from corners + function dist(x1, y1, x2, y2) { + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); + } + + const corners = [ + [left + cornerR, top + cornerR], + [right - cornerR, top + cornerR], + [left + cornerR, bottom - cornerR], + [right - cornerR, bottom - cornerR], + ]; + + let minDist = Infinity; + for (const [cx2, cy2] of corners) { + minDist = Math.min(minDist, dist(px, py, cx2, cy2) - cornerR); + } + + // Edges + if (px >= left && px <= right) { + minDist = Math.min(minDist, py - top, bottom - py); + } + if (py >= top && py <= bottom) { + minDist = Math.min(minDist, px - left, right - px); + } + + return minDist; + } + + // Draw lightning bolt shape inside the rect + function insideLightning(px, py) { + // Normalize to center + const dx = px - cx; + const dy = py - cy; + const boltWidth = size * 0.2; + + // Lightning bolt: zigzag vertical path + const topY = -size * 0.25; + const midY = 0; + const bottomY = size * 0.25; + const leftX = -boltWidth * 0.3; + const rightX = boltWidth * 0.3; + + function segmentDist(sx, sy, x1, y1, x2, y2) { + const sdx = x2 - x1; + const sdy = y2 - y1; + const lenSq = sdx * sdx + sdy * sdy; + if (lenSq === 0) return Math.sqrt((sx - x1) ** 2 + (sy - y1) ** 2); + + let t = ((sx - x1) * sdx + (sy - y1) * sdy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const projX = x1 + t * sdx; + const projY = y1 + t * sdy; + return Math.sqrt((sx - projX) ** 2 + (sy - projY) ** 2); + } + + // Top segment from (leftX, topY) to (rightX, midY) + const seg1 = segmentDist(dx, dy, leftX, topY, rightX, midY); + // Middle segment + const seg2 = segmentDist(dx, dy, rightX, midY, rightX * 0.5, midY * 0.1); + // Bottom segment + const seg3 = segmentDist(dx, dy, rightX * 0.5, midY * 0.1, leftX, bottomY); + // Bottom-right segment + const seg4 = segmentDist(dx, dy, leftX, bottomY, rightX * 0.4, bottomY * 0.3); + + const minSeg = Math.min(seg1, seg2, seg3, seg4); + return minSeg < boltWidth * 0.25; + } + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const idx = (y * size + x) * 4; + const d = distToRectEdge(x, y); + + if (d < 0) { + // Inside the rounded rect background + pixels[idx] = BG[0]; + pixels[idx + 1] = BG[1]; + pixels[idx + 2] = BG[2]; + pixels[idx + 3] = BG[3]; + + // Draw the accent shape + if (insideLightning(x, y)) { + pixels[idx] = ACCENT[0]; + pixels[idx + 1] = ACCENT[1]; + pixels[idx + 2] = ACCENT[2]; + pixels[idx + 3] = ACCENT[3]; + } + } else if (d < 1.5) { + // Anti-aliased edge + const alpha = Math.max(0, 1 - d / 1.5); + pixels[idx] = BG[0]; + pixels[idx + 1] = BG[1]; + pixels[idx + 2] = BG[2]; + pixels[idx + 3] = Math.round(BG[3] * alpha); + } else { + // Transparent outside + pixels[idx] = 0; + pixels[idx + 1] = 0; + pixels[idx + 2] = 0; + pixels[idx + 3] = 0; + } + } + } + + return pixels; +} + +// Generate both sizes +const publicDir = path.join(__dirname, "..", "public"); + +[192, 512].forEach((size) => { + const pixels = drawIcon(size); + const png = createPNG(size, size, pixels); + const filePath = path.join(publicDir, `icon-${size}.png`); + fs.writeFileSync(filePath, png); + console.warn(`Created ${filePath} (${png.length} bytes)`); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b6d3d4f..3ef8779 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/providers/ThemeProvider"; import { WalletProvider } from "@/components/providers/WalletProvider"; +import { ServiceWorkerProvider } from "@/components/providers/ServiceWorkerProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -42,9 +43,11 @@ export default function RootLayout({ - - {children} - + + + {children} + + ); diff --git a/src/components/providers/ServiceWorkerProvider.tsx b/src/components/providers/ServiceWorkerProvider.tsx new file mode 100644 index 0000000..3d1be06 --- /dev/null +++ b/src/components/providers/ServiceWorkerProvider.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +export function ServiceWorkerProvider({ + children, +}: { + children: React.ReactNode; +}) { + const registered = useRef(false); + + useEffect(() => { + if (registered.current) return; + + if (typeof window === "undefined" || !navigator.serviceWorker) { + return; + } + + registered.current = true; + + navigator.serviceWorker + .register("/sw.js", { scope: "/" }) + .then((registration) => { + // Listen for updates + registration.addEventListener("updatefound", () => { + const installing = registration.installing; + if (!installing) return; + + installing.addEventListener("statechange", () => { + if (installing.state === "installed" && navigator.serviceWorker.controller) { + // New content is available — this will be used on next navigation + console.warn("[SW] New version available"); + } + }); + }); + }) + .catch((err) => { + console.warn("[SW] Registration failed:", err.message); + }); + }, []); + + return <>{children}; +} diff --git a/tests/components/ServiceWorkerProvider.test.tsx b/tests/components/ServiceWorkerProvider.test.tsx new file mode 100644 index 0000000..eeb8876 --- /dev/null +++ b/tests/components/ServiceWorkerProvider.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { ServiceWorkerProvider } from "../../src/components/providers/ServiceWorkerProvider"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function createMockRegistration(): any { + const listeners: Array<{ + type: string; + handler: EventListenerOrEventListenerObject; + }> = []; + const state = "installing"; + + const mockInstalling = { + state, + addEventListener: vi.fn( + (event: string, handler: EventListenerOrEventListenerObject) => { + listeners.push({ type: event, handler }); + } + ), + }; + + const mockRegistration = { + installing: mockInstalling, + addEventListener: vi.fn( + (event: string, handler: EventListenerOrEventListenerObject) => { + listeners.push({ type: event, handler }); + } + ), + _triggerStateChange: (newState: string) => { + mockInstalling.state = newState; + listeners + .filter((l) => l.type === "statechange") + .forEach((l) => { + if (typeof l.handler === "function") { + l.handler(new Event("statechange")); + } else { + l.handler.handleEvent(new Event("statechange")); + } + }); + }, + _triggerUpdateFound: () => { + listeners + .filter((l) => l.type === "updatefound") + .forEach((l) => { + if (typeof l.handler === "function") { + l.handler(new Event("updatefound")); + } else { + l.handler.handleEvent(new Event("updatefound")); + } + }); + }, + }; + + return mockRegistration; +} + +describe("ServiceWorkerProvider", () => { + let originalServiceWorker: ServiceWorkerContainer | undefined; + + beforeEach(() => { + originalServiceWorker = (navigator as { serviceWorker?: ServiceWorkerContainer }) + .serviceWorker; + // @ts-expect-error – mock serviceWorker for tests + navigator.serviceWorker = undefined; + }); + + afterEach(() => { + // @ts-expect-error – restore original + navigator.serviceWorker = originalServiceWorker; + vi.restoreAllMocks(); + }); + + it("should register the service worker when supported", async () => { + const mockReg = createMockRegistration(); + const registerMock = vi.fn().mockResolvedValue(mockReg); + + // @ts-expect-error – mock serviceWorker + navigator.serviceWorker = { + register: registerMock, + controller: null, + addEventListener: vi.fn(), + }; + + render( + +
App content
+
+ ); + + // Wait for useEffect + await vi.waitFor(() => { + expect(registerMock).toHaveBeenCalledWith("/sw.js", { scope: "/" }); + }); + }); + + it("should render children", () => { + // @ts-expect-error – mock serviceWorker + navigator.serviceWorker = { + register: vi.fn().mockResolvedValue(createMockRegistration()), + controller: null, + addEventListener: vi.fn(), + }; + + const { getByText } = render( + +
App content
+
+ ); + + expect(getByText("App content")).toBeDefined(); + }); + + it("should not crash when service workers are unsupported", () => { + // navigator.serviceWorker is already undefined from beforeEach + + const { getByText } = render( + +
No SW support
+
+ ); + + expect(getByText("No SW support")).toBeDefined(); + }); + + it("should handle registration failure gracefully", async () => { + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + + // @ts-expect-error – mock serviceWorker + navigator.serviceWorker = { + register: vi.fn().mockRejectedValue(new Error("SW registration failed")), + controller: null, + addEventListener: vi.fn(), + }; + + const { getByText } = render( + +
Content
+
+ ); + + await vi.waitFor(() => { + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[SW] Registration failed:", + "SW registration failed" + ); + }); + + expect(getByText("Content")).toBeDefined(); + consoleWarnSpy.mockRestore(); + }); + + it("should listen for updatefound event", async () => { + const mockReg = createMockRegistration(); + const registerMock = vi.fn().mockResolvedValue(mockReg); + + // @ts-expect-error – mock serviceWorker + navigator.serviceWorker = { + register: registerMock, + controller: null, + addEventListener: vi.fn(), + }; + + render( + +
Content
+
+ ); + + await vi.waitFor(() => { + expect(registerMock).toHaveBeenCalled(); + }); + + expect(mockReg.addEventListener).toHaveBeenCalledWith( + "updatefound", + expect.any(Function) + ); + }); + + it("should guard against missing navigator.serviceWorker", () => { + // navigator.serviceWorker is already undefined from beforeEach + const registerSpy = vi.fn(); + + // @ts-expect-error – mock serviceWorker + navigator.serviceWorker = undefined; + + // Should not throw + const { getByText } = render( + +
Safe render
+
+ ); + + expect(getByText("Safe render")).toBeDefined(); + expect(registerSpy).not.toHaveBeenCalled(); + }); +});