Compare commits

...

274 Commits

Author SHA1 Message Date
Matt Walsh
8b076db25d 6.2.4 2025-10-17 00:51:09 +00:00
Matt Walsh
807932fe3c Merge branch 'ios-regex' close #137 2025-10-17 00:49:59 +00:00
Matt Walsh
7bb024eff5 6.2.3 2025-10-17 00:36:14 +00:00
Matt Walsh
f4a1a3a1d8 add hazards before custom scroll options close #149 2025-10-17 00:35:26 +00:00
Matt Walsh
9a5efe9d48 update dependencies 2025-10-17 00:14:59 +00:00
Matt Walsh
58e0611a46 6.2.2 2025-10-16 19:00:30 -05:00
Matt Walsh
9ed496c892 better formatting for headend info 2025-10-16 19:00:20 -05:00
Matt Walsh
31315d1ace add com.chrome.devtools.json 2025-10-16 18:36:41 -05:00
Matt Walsh
77838e1a81 use locally stored weather parameters in spc outlook close #150 2025-10-15 00:29:23 +00:00
Matt Walsh
64d6484bd8 Merge pull request #151 from bparkin1283/patch-1
Update README.md clarifying displays if you're within one of the high…
2025-10-09 11:17:13 -05:00
bparkin1283
20cab8c25e Update README.md clarifying displays if you're within one of the highlight areas 2025-10-09 10:55:33 -05:00
Matt Walsh
b4de17ccd0 update dependencies 2025-10-02 21:50:28 -05:00
Matt Walsh
0fd90feb7a update community notes 2025-10-02 21:37:36 -05:00
Matt Walsh
8c3b596b69 add build script for travel cities #146 2025-10-02 21:26:45 -05:00
Matt Walsh
e57b9bcb20 6.2.1 2025-09-24 22:33:59 -05:00
Matt Walsh
e27750e915 fix load order on scroll when compiled 2025-09-24 22:33:47 -05:00
Matt Walsh
f5431a04c7 6.2.0 2025-09-24 22:27:44 -05:00
Matt Walsh
5117a9d475 move bottom scroll to single div #144 2025-09-24 22:27:31 -05:00
Matt Walsh
28baa022a9 6.1.11 2025-09-15 08:54:20 -05:00
Matt Walsh
e8b8890260 fix full screen centering on chrome #139 2025-09-15 08:54:11 -05:00
Matt Walsh
b797a10b9e 6.1.10 2025-09-15 08:04:24 -05:00
Matt Walsh
2a64cda383 Merge branch 'fullscreen-kiosk-sizing' 2025-09-15 08:03:53 -05:00
Matt Walsh
e6e357c51b separate full screen container and scaling #139 2025-09-15 08:01:28 -05:00
Matt Walsh
24deb4dce4 additional full screen scaling calculation adjustments 2025-09-11 15:34:02 -05:00
Matt Walsh
14b1891efd direct check of regex lookbehind capability 2025-09-11 08:47:16 -05:00
Matt Walsh
f17f69f60e 6.1.9 2025-09-09 22:07:51 -05:00
Matt Walsh
fa16095355 filter for actual alerts (not test) close #141 2025-09-09 22:07:42 -05:00
Matt Walsh
cc3dbeb043 6.1.8 2025-09-09 21:35:51 -05:00
Matt Walsh
8ee1e954eb better background and wide screen for hazard displays 2025-09-09 21:35:31 -05:00
Matt Walsh
bfc4bddfef Add quick start to readme 2025-09-09 20:54:13 -05:00
Matt Walsh
567325e3c5 update dependencies 2025-09-09 20:47:40 -05:00
Matt Walsh
4903b95fec 6.1.7 2025-09-09 20:26:37 -05:00
Matt Walsh
b43fb32820 Merge branch 'ios-metar-regex' 2025-09-09 20:26:29 -05:00
Matt Walsh
0d0c4ec452 fix Dockerfile.server build close #142 2025-09-09 20:06:54 -05:00
Matt Walsh
49d18c2fbe 6.1.6 2025-09-09 19:36:33 -05:00
Matt Walsh
1732a3381f fix hazards displaying when disabled (sometimes) close #140 2025-09-09 19:36:23 -05:00
Matt Walsh
cc05aafb95 patch for kiosk drawing off screen 2025-09-09 19:22:18 -05:00
Matt Walsh
093b6ac239 unique version numbers for staging uploads 2025-09-04 21:57:12 -05:00
Matt Walsh
12d068d740 playlist info in readme close #138 2025-09-04 21:26:01 -05:00
Matt Walsh
517c560ef6 don't parse metar for old ios versions 2025-09-03 21:46:48 -05:00
Matt Walsh
3eb571bed4 update community notes in readme close #135 2025-08-23 16:28:02 -05:00
Matt Walsh
52ca161bdb 6.1.5 2025-08-15 15:13:39 -05:00
Matt Walsh
ee5690dcad Merge branch 'station-data' 2025-08-15 15:01:00 -05:00
Matt Walsh
c05b827593 move station post processor inline with api gets 2025-08-15 14:59:16 -05:00
Matt Walsh
bef42a3da2 6.1.4 2025-08-11 22:35:18 -05:00
Matt Walsh
13ff0317e6 fix nighttime icons on extended forecast close #134 2025-08-11 22:35:03 -05:00
Matt Walsh
5cc85840a9 6.1.3 2025-08-11 22:15:28 -05:00
Matt Walsh
190e50e2f3 add debugging information to forecast info box 2025-08-11 22:15:16 -05:00
Matt Walsh
aa7ac64827 6.1.2 2025-08-10 20:56:50 -05:00
Matt Walsh
2ab737d5a5 update dependencies 2025-08-10 20:56:40 -05:00
Matt Walsh
ecf0999675 6.1.1 2025-08-10 20:21:44 -05:00
Matt Walsh
6a49b7b6ce fix Washington DC truncated on regional maps close #133 2025-08-10 20:20:53 -05:00
Matt Walsh
5ffff03db9 6.1.0 2025-08-10 20:05:16 -05:00
Matt Walsh
c8a25e5d9a Integrate no-data icon close #94 #128 2025-08-10 20:05:07 -05:00
Matt Walsh
ea58b5a9c8 6.0.1 2025-08-05 22:51:15 -05:00
Matt Walsh
4bf725413b fix star4000 large font 2025-08-05 22:50:40 -05:00
Matt Walsh
75eb81887f Clean up build around metar parser and locale 2025-08-04 12:03:18 -05:00
Matt Walsh
9b37bc5c52 6.0.0 2025-08-03 22:10:41 -05:00
Matt Walsh
8a22e23d5a Merge remote-tracking branch 'eddyg/station-name-improvements' into code-refactor 2025-08-03 22:10:17 -05:00
Matt Walsh
0d508d7f50 5.27.3 2025-07-30 22:29:25 -05:00
Matt Walsh
d85a5ed3b1 add strings to permalink structure close #129 2025-07-30 22:29:20 -05:00
Matt Walsh
831e1680e9 Merge pull request #128 from jacroe/feature/94-drabbit
Add "No Data" icon
2025-07-30 22:05:09 -05:00
Jacob Roeland
73cbc0aa81 Add "No Data" icon
Supports #94
2025-07-25 21:23:23 -04:00
Eddy G
eb412a0cae Add intelligent station name processor
This is intended to replace the simple regex truncation (`city.match(/[^,/;\\-]*/)[0].substr(0, 12)`)
with a file that has all the station name processing already pre-processed by the logic in the stations
post-processor script. It uses priority-based conflict resolution to avoid duplicate station names by
using airport classification hierarchy. It handles complex naming patterns including military facilities,
county airports, hyphenated cities, slash/comma separators, and "at/near" qualifiers with a minium number
of exceptions.

This changes station data pipeline to generate stations-raw.json first, then process through the
post-processor to create the final stations.json compatible with existing codebase.
2025-07-17 16:10:34 -04:00
Matt Walsh
9150d42802 5.27.2 2025-07-15 22:13:04 -05:00
Matt Walsh
54257e4667 add new naming issue template 2025-07-15 22:07:52 -05:00
Matt Walsh
7d50ce28bd Allow for station data overrides #125 2025-07-15 22:00:33 -05:00
Eddy G
c3d863f89f Fixup mapclick.mjs 2025-07-13 22:45:04 -04:00
Eddy G
996baa78aa Add MapClick adapter and "fallback" logic when observations are stale
- Create utils/mapclick.mjs with centralized MapClick API functionality
- Refactor modules to use the new utility:
  - Current Weather
  - Latest Observations
  - Regional Forecast
- Add staleness checking utility for use by modules
2025-07-13 22:10:41 -04:00
Eddy G
e81c957416 Remove legacy mapclick icon references
- https://api.weather.gov/icons should be the only icons used by api.weather.gov
2025-07-13 19:45:52 -04:00
Eddy G
d25a632f7d Switch back to using j69.ejs-beautify
- Reformat index.mjs
- Don't preload playlist.json to avoid Safari warning about
  unused preloaded resource
2025-07-13 19:45:52 -04:00
Eddy G
4b34ffabcb Fix retry count display in doFetch 2025-07-13 19:45:52 -04:00
Matt Walsh
2db7f30de7 5.27.1 2025-07-11 22:36:57 -05:00
Matt Walsh
5c7a6ab1a4 fix for rss feed encoding types close #124 2025-07-11 22:36:47 -05:00
Eddy G
ee4f84689a Add rule to allow devDependencies in dev-related config files 2025-07-07 12:51:09 -04:00
Eddy G
804d9e9e33 Centralize icon URL parsing; improve icon error handling
- Move common icon parsing logic into module
- Return a "real" icon value during error handling to avoid downstream
  consumers from trying to use an icon named "false"
- Use named regex to parse icon URLs based on API specification
2025-07-07 12:43:32 -04:00
Eddy G
3e8135a36a Improve settings initialization timing with deferred DOM updates
Defer DOM updates to ensure settings are properly applied
even when read from localStorage before the DOM is available.
2025-07-07 12:43:32 -04:00
Eddy G
9c5ed0dcca Improve error handling to help prevent runtime errors
Adds input validation and safe property access to utility functions
to handle edge cases and invalid arguments gracefully
2025-07-07 12:43:32 -04:00
Eddy G
a3c581aa93 Clarify instructions for iOS/iPadOS and Android 2025-07-07 12:43:32 -04:00
Eddy G
771ab37eaf Improve cache error handling in various edge cases
- Add null-safe operations and fallbacks for missing properties
- Simplify retry logic and enhance error logging
- Enable default retries and improve timeout handling
2025-07-07 12:43:32 -04:00
Matt Walsh
4b63328b74 update dependencies 2025-07-06 10:54:20 -05:00
Matt Walsh
ae1d004f60 Merge pull request #123 from rmitchellscott/fix-static-envs
fix: url encode envs in static-env-handler. Fixes #122.
2025-07-06 10:36:51 -05:00
Eddy G
2a975d4d6d Fix auto-play logic for sticky kiosk mode
The previous logic only checked URL parameters for kiosk mode,
but sticky kiosk can enable kiosk mode from localStorage without
URL parameters. Now checks actual kiosk setting value.

Fix typo in scan line settings name.
2025-07-04 14:55:57 -04:00
Mitchell Scott
7dd4c1dd24 fix: url encode envs in static-env-handler. Fixes #122. 2025-07-04 04:41:56 -06:00
Eddy G
10baefc901 Merge remote-tracking branch 'upstream/main' into modernization-and-refactor 2025-07-02 09:08:07 -04:00
Eddy G
46edf1f7e2 Add "Settings" section to README 2025-07-02 08:44:12 -04:00
Eddy G
67dfd7ec08 Add "Sticky Kiosk" setting that stores "Kiosk" mode when activate
- This setting is important to allow creation ofa Home Screen app on iOS/iPadOS
- If kiosk mode is accidentally made sticky, it can be cleared by adding '&kiosk=false` to the URL
- Ctrl-K will now also exit kiosk mode

This adds a `stickyRead` parameter to settings, that means "read it if it's there, but don't write it"
2025-07-02 08:11:52 -04:00
Eddy G
2761f76117 Build CSS changes 2025-07-02 08:11:52 -04:00
Eddy G
13621b6f46 Stop Mobile Safari from trying to auto-fill the location box
This change prevents the "No items to show" popup that obscures the first
couple suggested matches.
2025-07-02 08:11:52 -04:00
Eddy G
b49433f5ff Add responsive scaling; improve scanlines and Mobile Safari support
- Replace CSS zoom with CSS transform scaling for better mobile compatibility
- Implement wrapper-based scaling approach that includes both content and navigation bar
- Replace Almanac layout with CSS Grid for better cross-browser layout
- Greatly improve scanline algorithm to handle a wide variety of displays
- Add setting to override automatic scanlines to user-specified scale factor
- Remove scanline scaling debug functions
- Refactor settings module: initialize settings upfront and improve change handler declarations
- Enhance scanline SCSS with repeating-linear-gradient for better performance
- Add app icon for iOS/iPadOS
- Add 'fullscreen' event listener
- De-bounce 'resize' event listener
- Add 'orientationchange' event listener
- Implement three resize scaling algorithms:
  - Baseline (when no scaling is needed, like on the index page)
  - Mobile scaling (except Mobile Safari kiosk mode)
  - Mobile Safari kiosk mode (using manual offset calculations)
  - Standard fullscreen/kiosk mode (using CSS centering)
2025-07-02 00:22:06 -04:00
Matt Walsh
1120247c99 include custom rss feed in build #57 2025-06-30 23:32:30 -05:00
Matt Walsh
c5c01e5450 5.27.0 2025-06-30 23:29:46 -05:00
Matt Walsh
0a65221905 update readme for custom rss close #57 2025-06-30 23:29:39 -05:00
Eddy G
cc9e613ba7 Merge remote-tracking branch 'upstream/main' into modernization-and-refactor 2025-06-29 22:42:03 -04:00
Matt Walsh
9f9667c895 add single text scroll option #57 2025-06-28 09:20:36 -05:00
Matt Walsh
fda44e95fc rss feeds scroll, needs additional testing #57 2025-06-28 00:59:40 -05:00
Matt Walsh
945c12e6c6 parse rss feed #57 2025-06-28 00:29:55 -05:00
Matt Walsh
0fde88cd8f restructure current weather scroll to allow add/remove of rss feed #57 2025-06-28 00:29:47 -05:00
Matt Walsh
c6af9a2913 5.26.2 2025-06-27 22:30:05 -05:00
Matt Walsh
11eba84cdb fix for calm/0mph wind close #121 2025-06-27 22:29:56 -05:00
Matt Walsh
b9ead38015 5.26.1 2025-06-27 22:17:00 -05:00
Matt Walsh
3d0178faa1 radar scrolling fix for ios 2025-06-27 22:16:51 -05:00
Eddy G
b1c4e6d850 Merge remote-tracking branch 'upstream/main' into modernization-and-refactor 2025-06-27 18:21:32 -04:00
Matt Walsh
8a2907e02c fix display of null wind speed 2025-06-27 15:35:15 -05:00
Matt Walsh
b870ce1c01 store already processed radar images for reuse on silent reload 2025-06-27 15:29:20 -05:00
Eddy G
90c1ab92b4 Merge upstream/main: integrate radar worker removal and improvements
- Remove radar-worker.mjs and integrate functionality into radar-processor.mjs
- Update package.json and gulpfile.mjs with upstream changes
2025-06-27 11:05:32 -04:00
Matt Walsh
15107ffe1c 5.26.0 2025-06-27 08:56:14 -05:00
Matt Walsh
efd4e0c66d Remove workers from build processes 2025-06-27 08:56:04 -05:00
Matt Walsh
652d7c5fb0 Merge remote-tracking branch 'origin/radar-no-worker' #74 #140 2025-06-27 08:54:50 -05:00
Matt Walsh
5a80f43f30 add staging gulp tasks 2025-06-26 22:30:42 -05:00
Matt Walsh
6d090cb1c7 streamline radar tile layout calculation #74 2025-06-26 21:23:44 -05:00
Eddy G
17585e97c4 Update README to clarify deployment modes and music handling
- Add clear distinction between Server and Static deployment modes
- Restructure run instructions with specific npm commands and their purposes
- Clarify Docker deployment scenarios (static vs server, with examples)
- Rewrite music section to explain server-side vs browser-side playlist generation
2025-06-26 20:39:18 -04:00
Eddy G
517cafe40a Refactor data loading: move from inline JSON to client-side fetch
- Remove large JSON data injection from EJS templates
- Add client-side data-loader utility with cache-busting support
- Create server endpoints for JSON data with long-term caching
- Add graceful failure handling if core data fails to load
- Copy JSON data files to dist/data for static hosting
- Update app initialization to load data asynchronously
- Set serverAvailable flag for static builds in gulp task

This reduces HTML payload size and enables better caching strategies
for both server and static deployment modes.
2025-06-26 20:10:15 -04:00
Eddy G
7f7cb96231 Add STATIC environment variable for browser-only deployment mode
Implement STATIC=1 environment variable to enable browser-only deployment
without proxy server infrastructure. Uses WS4KP_SERVER_AVAILABLE flag to
distinguish between server-backed and static deployments for proper URL
rewriting and User-Agent header handling.

- Add STATIC env var to skip proxy route registration at startup
- Inject WS4KP_SERVER_AVAILABLE flag via EJS template based on STATIC mode
- Update fetch.mjs to conditionally send User-Agent headers based on server availability
- Update url-rewrite.mjs to skip proxy rewriting when server is unavailable
- Use renderIndex helper for consistent template data across dev/prod modes
- Improve music playlist logging

Benefits of integrated approach:
- Single environment variable controls both server and client behavior
- Flag injection happens once at render time, not on every request
- No runtime HTML string manipulation overhead
- Clean separation between server-backed and static deployment logic
- Same codebase supports both deployment modes without duplication

Static mode (STATIC=1): Direct API calls to external services, no caching
Server mode (default): Local proxy with caching and API request observability
2025-06-26 20:10:11 -04:00
Eddy G
bfd0c2b02d Add 'no-unused-vars' rule for vars starting with _ 2025-06-24 23:54:59 -04:00
Eddy G
c8b520b752 Build CSS changes 2025-06-24 23:54:23 -04:00
Eddy G
ebface1749 Add comment about not needing to use safeJson()
- Fix indentation in media.mjs
2025-06-24 23:53:44 -04:00
Eddy G
137c2f6d08 Don't set to failed if not enabled
- Allow any falsey value to indicate failure
2025-06-24 23:42:22 -04:00
Eddy G
bec80a1ebe Refactor alert/hazard system with timing and display improvements
- Replace magic numbers with seconds-based timing constants
- Switch from scrollTo() to hardware-accelerated transform
- Add scroll caching to prevent repeated DOM queries every scroll cycle
- Switch to safeJson() for centralized error handling across alert modules
- Horizontal alert scroll now goes edge-to-edge
- Integrate global speed settings into horizontal scroll timing
- Improve error handling flow with better fallback behavior for missing data
- Remvoe unused getCurrentData() function in hazards.mjs
- Move background color from scrolling element to container to avoid
    showing the underlying content when scrolling with trasnform
2025-06-24 23:41:44 -04:00
Eddy G
e34137e430 Refactor timing calculations and improve scroll performance
- Replace magic numbers with seconds-based timing constants
- Switch from scrollTo() to hardware-accelerated transform for smooth scrolling
- Add scroll caching to prevent repeated DOM queries every scroll cycle
- Fix calculations to support flexible hourly forecast lengths
    (i.e. in the future, could offer every other hour)
- Switch to safeJson() for centralized error handling
2025-06-24 23:40:07 -04:00
Eddy G
c0e2eaf33a Refactor timing calculations and improve scroll performance
- Replace magic numbers with seconds-based timing constants
- Switch from scrollTo() to hardware-accelerated transform
- Add scroll caching to prevent repeated  DOM queries every scroll cycle
- Fix calculations to allow an arbitrarily-sized list of travel forecast cities
- Switch to safeJson() and safePromiseAll() for centralized error handling
2025-06-24 23:39:40 -04:00
Eddy G
975bccdac5 Switch from json() to safeJson() for centralized error handling
- Prevent infinitie recursion by ensuring the same display is not
    selected in loadDisplay()
2025-06-24 23:37:47 -04:00
Eddy G
8ead95c041 Improve error handling and API efficiency
- Switch to safe*() methods for centralized error handling
- Add error handling and validation
- Optimize radar API usage by only fetching yesterday's data when needed
- Use centralized URL rewriting for caching proxy support
- Add debug logging throughout radar processing pipeline
- Improve canvas context validation and error recovery
- Handle worker errors gracefully by setting totalScreens = 0 to skip in animation
- Remove unused OVERRIDES parameter passing to workers
2025-06-24 23:35:41 -04:00
Eddy G
8f34aa5139 Improve error handling
- Use safeJson() and safePromiseAll() for centralized error handling
- Enhance logging with structured debug flags
2025-06-24 23:10:52 -04:00
Eddy G
e472b99b44 Improve SPC Outlook data fetching and error handling
- Switch to safeJson() and safePromiseAll() for centralized error handling
- Only set failed state if enabled
2025-06-24 23:09:18 -04:00
Eddy G
09a21e6f5a Add content-aware transition timing; remove expired forecasts
- DOM-based measurement system for accurate forecast lines
- Replace fixed-timing with dynamic timing based on actual forecast lines
- Filter out expired forecasts
- Improve error handling and only set failed state if enabled
- Debug logging for timing calculations and content measurement
- Switch from json() to safeJson() for centralized error handling
2025-06-24 23:08:25 -04:00
Eddy G
dd680c61b0 Enhance extended forecast parsing and error handling
- Add module for expired period filtering
- Switch from json() to safeJson() for centralized error handling
- Improve nighttime period handling to focus on full days
- Fix day/night temperature pairing logic
- Add debug logging
2025-06-24 23:07:47 -04:00
Eddy G
79de691eef Augment missing weather data from METAR when possible; use centralized error handling
- Add utility function to augment missing weather observation data from METAR
- Switch from json() to safeJson() for centralized error handling
- Data quality validation and age checks
- Add null/undefined value handling for wind direction calculations
2025-06-24 23:05:51 -04:00
Eddy G
ec83c17ae2 Add 'metar-taf-parser' npm dependency
- Adds METAR (Meteorological Aerodrome Report) parsing capabilities
- metar-taf-parser has no dependencies, so impact is small
2025-06-24 22:58:51 -04:00
Eddy G
5630067530 Fix auto-refresh to use weather parameters; add timing debug logging
- Fixes issue where auto-refresh could fail due to missing weather parameters
2025-06-24 22:56:08 -04:00
Eddy G
506ac1f4df Add 'start'/'stop' and more files to lint targets 2025-06-24 22:54:37 -04:00
Eddy G
0e0ea3c378 Add debug flag management system
- Default debug flags settable by query param or env variable
- Allows comma-separated list of debug flags
- Runtime modification (enable/disable/set) via console
- Support "all" flag for enabling/disabling everything
2025-06-24 22:53:25 -04:00
Eddy G
bf65b8e426 Update VS Code settings
- "eslint.validate": [ "javascript" ] is no longer needed
    (Modern versions of the ESLint VS Code extensions
    automatically validate JavaScript files by default.)
- Configure editor to use tabs instead of spaces per ESLint rules
- Switch to VS Code's built-in HTML formatter
- Add EJS file associations for proper syntax highlighting
- Add recommended extensions for development workflow
2025-06-24 22:52:59 -04:00
Eddy G
ca272de8bf Replace separate .js data files with server-side JSON injection
- Remove need to maintain duplicate .js data files alongside JSON sources
- Load JSON data once at server startup and inject into templates
- Dev and production modes use the same data sources

Removes:
- server/scripts/data/{travelcities,regionalcities,stations}.js
- gulp compressJsData task for bundling data files
2025-06-24 22:51:09 -04:00
Eddy G
65944dc3b5 Add comprehensive responsive scanline scaling system with anti-aliasing
- Attempt pixel-perfect scanline rendering for scaled displays and zoom scenarios
- Implement dynamic scanline thickness calculation to prevent sub-pixel rendering issues
- Add enhanced kiosk detection via isKioskLike for better fullscreen optimization
- Optimize scanlines for specific kiosk resolutions (1024x768, 1023x767)
- Add responsive SCSS media queries for different display densities
- Include extensive debugging utilities for scanline troubleshooting
- Improve noSleep error handling with proper promise rejection handling
- Update to modern fullscreen API method names
- Add async/await error handling for fullscreen requests
- Trigger resize after fullscreen engagement to apply optimal scaling
2025-06-24 22:38:25 -04:00
Eddy G
be41d66de9 Improve kiosk mode startup experience
- Pass query parameters to EJS template for kiosk mode detection
- Add kiosk class to body when enabled via query parameter
- Simplify kiosk mode CSS to hide all elements except main weather display
- Add null checks for progress object to prevent errors in kiosk mode
- Prevent navigation errors when no suitable displays are available
2025-06-24 22:30:16 -04:00
Eddy G
7a07c67e84 Replace CORS proxy with complete server-side cache
- Replace cors/ directory and cors.mjs utility with comprehensive
    HTTP caching proxy in proxy/ directory
- Implement RFC-compliant caching with cache-control headers,
    conditional requests, and in-flight deduplication
- Centralized error handling with "safe" fetch utilities
- Add unified proxy handlers for weather.gov, SPC, radar, and mesonet APIs
- Include cache management endpoint and extensive diagnostic logging
- Migrate client-side URL rewriting from cors.mjs to url-rewrite.mjs
2025-06-24 20:45:43 -04:00
Matt Walsh
b5fa3e49d6 remove radar-worker and offscreen canvas to make things easier for ios #74 2025-06-20 22:04:00 -05:00
Matt Walsh
ef0b60a0b8 Merge pull request #117 from rmitchellscott/fix-radar-mime-chrome
fix: radar mime-type in chrome in Docker
2025-06-20 21:12:40 -05:00
Mitchell Scott
dc13140cc4 fix: radar mime-type in chrome 2025-06-20 08:40:52 -06:00
Matt Walsh
5414b1f5bc Merge pull request #116 from arazilsongweaver/bugfix-docker-nginx-config-add-mime-type
Docker: Add mime.types To nginx Configuration
2025-06-20 09:20:49 -05:00
Arazil
1fdc3635e6 Docker: Add mime.types To nginx Configuration
Explicit configuration of nginx MIME types is required for the proper operation the radar viewer.
2025-06-20 08:06:32 -05:00
Matt Walsh
e2cc86cddd 5.25.3 2025-06-19 23:30:56 -05:00
Matt Walsh
92181c716d clean up linking to radar worker 2025-06-19 23:30:44 -05:00
Matt Walsh
208ca3d87f 5.25.2 2025-06-19 22:50:17 -05:00
Matt Walsh
7167bb18fb allow one missing valueon current conditoins #94 #114 2025-06-19 22:50:09 -05:00
Matt Walsh
daa81ebf94 Merge branch 'rmitchellscott-static-docker' 2025-06-19 21:57:08 -05:00
Matt Walsh
543a8df9a2 Merge branch 'static-docker' of github.com:rmitchellscott/ws4kp into rmitchellscott-static-docker 2025-06-19 21:56:39 -05:00
Matt Walsh
b1347bcc3c 5.25.1 2025-06-16 23:10:49 -05:00
Matt Walsh
c7e170b1a3 fix for black radar background on mobile 2025-06-16 23:10:37 -05:00
Matt Walsh
3d75384848 build tool cleanup 2025-06-16 16:00:16 -05:00
Matt Walsh
bf4819b241 5.25.0 2025-06-16 15:48:20 -05:00
Matt Walsh
7dd6050416 Merge branch 'radar-layers' 2025-06-16 15:47:58 -05:00
Matt Walsh
bb0ad8ff32 radar code cleanup #74 #111 2025-06-16 15:47:24 -05:00
Matt Walsh
9eb192146a layers as pre-stretched tiles #74 #111 2025-06-16 15:47:23 -05:00
Matt Walsh
1b6e6ad142 lossless radar tiles 2025-06-16 13:39:54 -05:00
Matt Walsh
f0d4a9e6f0 change radar tile sizes 2025-06-16 13:26:35 -05:00
Matt Walsh
a2cbe7f5c8 stretched tiles 2025-06-14 12:11:41 -05:00
Matt Walsh
57395b8dc7 update eslint via compatability layer with airbnb-base 2025-06-14 11:35:33 -05:00
Mitchell Scott
51bb9696b0 add x-weatherstar header for specific 404 detection 2025-06-13 22:14:14 -06:00
Matt Walsh
a11e783cde 5.24.1 2025-06-13 22:29:35 -05:00
Matt Walsh
e2e22517b6 fix webpack for new radar-worker 2025-06-13 22:27:36 -05:00
Matt Walsh
d8e0399e92 5.24.0 2025-06-13 22:01:59 -05:00
Matt Walsh
f456897520 use background and foreground tiles for fixed parts of the radar #74 #111 2025-06-13 22:01:06 -05:00
Matt Walsh
9303035bb9 tile position correction 2025-06-13 17:58:05 -05:00
Matt Walsh
3c40219003 tile background created, need to fix shifting of tile 2025-06-13 16:44:53 -05:00
Mitchell Scott
6ff7122844 env workaround for static build 2025-06-13 14:51:06 -06:00
Mitchell Scott
9f94ef83ba human readable song title 2025-06-13 10:24:03 -06:00
Mitchell Scott
3236b2ecc3 radar fix for static deployment, slight spelling and grammer fixes in readme 2025-06-13 09:04:39 -06:00
Mitchell Scott
2827913d42 add client-side generation for playlist.json for static builds 2025-06-13 08:37:01 -06:00
Mitchell Scott
1ac514293b create static nginx docker build 2025-06-12 21:35:18 -06:00
Matt Walsh
392b339727 5.23.7 2025-06-12 20:53:28 -05:00
Matt Walsh
852eff8de6 add basic volume control #109 2025-06-12 20:53:23 -05:00
Matt Walsh
c74a15c40c 5.23.6 2025-06-12 13:17:26 -05:00
Matt Walsh
5419425834 fix for large font linux/win differences in extended forecast 2025-06-12 13:16:55 -05:00
Matt Walsh
f3a386079b fix for file didn't load in spc outlook 2025-06-12 13:13:19 -05:00
Matt Walsh
aa43713943 5.23.5 2025-06-12 12:15:10 -05:00
Matt Walsh
1dece10679 cache-busting for radar worker 2025-06-12 12:14:56 -05:00
Matt Walsh
c4f16d786a Merge remote-tracking branch 'origin/static-host' 2025-06-12 12:07:55 -05:00
Matt Walsh
36b8adc019 5.23.4 2025-06-12 09:30:54 -05:00
Matt Walsh
bfe0b4757d Merge branch 'main' of github.com:netbymatt/ws4kp 2025-06-12 09:30:38 -05:00
Matt Walsh
2b61e55783 get current conditions direct from api 2025-06-12 09:30:31 -05:00
Matt Walsh
36c4f451b3 Merge pull request #107 from rmitchellscott/fix-push-on-tag
chore(docker): fix pushing on tag
2025-06-09 15:12:38 -05:00
Mitchell Scott
268d4ae7fa chore(docker): fix pushing on tag 2025-06-07 11:46:00 -06:00
Matt Walsh
1b49e02cd8 5.23.3 2025-06-06 16:40:14 -05:00
Matt Walsh
9a55a6ec39 fix setting boolean-style query string parsing close #106 2025-06-06 16:40:08 -05:00
Matt Walsh
faaf6f770f 5.23.2 2025-06-06 16:31:13 -05:00
Matt Walsh
79e4ed6e8b clean up star4000 large font baseline differences between linux/win 2025-06-06 16:31:05 -05:00
Matt Walsh
f956df1272 5.23.1 2025-06-05 23:34:47 -05:00
Matt Walsh
089ef56b10 add event type to hazard scroll #92 2025-06-05 23:34:25 -05:00
Matt Walsh
c4e8721a2b 5.23.0 2025-06-05 22:02:07 -05:00
Matt Walsh
a2efc2f767 Merge branch 'hazard-scroll-2' #92 2025-06-05 22:01:23 -05:00
Matt Walsh
c0e1c55453 clean up location switching 2025-06-05 21:57:06 -05:00
Matt Walsh
860ca52e2d 5.22.0 2025-06-05 20:47:32 -05:00
Matt Walsh
b891a1e3c0 Merge remote-tracking branch 'origin/radar-tiles' 2025-06-05 20:42:21 -05:00
Matt Walsh
70fb3b5dbe change to radar as a set of tiles for a smaller image download 2025-06-05 14:01:49 -05:00
Matt Walsh
5bcc744867 Merge pull request #104 from rmitchellscott/remove-armv7
Remove ARMv7 from Docker builds
2025-06-05 09:42:44 -05:00
Mitchell Scott
da75226a63 chore(docker)!: remove armv7 support 2025-06-05 06:10:21 -06:00
Matt Walsh
cab4219740 5.21.15 2025-06-04 23:39:42 -05:00
Matt Walsh
9252275436 star large font change to woff for size 2025-06-04 23:39:34 -05:00
Matt Walsh
9d1c21d8ef Merge pull request #103 from rmitchellscott/node24
chore: update dockerfile to Node 24 (LTS)
2025-06-04 21:13:22 -05:00
Matt Walsh
6473f167a8 Merge pull request #102 from rmitchellscott/docker-version-tags
chore: update docker build action, also run on tag action
2025-06-04 21:12:35 -05:00
Mitchell Scott
d280a5b3a9 chore: update dockerfile to Node 24 (LTS) 2025-06-04 14:29:16 -06:00
Mitchell Scott
b195ce042b chore: update docker build action, also run on tag action 2025-06-04 14:13:53 -06:00
Matt Walsh
39e8879697 5.21.14 2025-06-03 21:10:19 -05:00
Matt Walsh
5e3b917023 add music track names to info block close #100 2025-06-03 21:09:55 -05:00
Matt Walsh
a813ee19a7 Merge branch 'gordonthree-main' 2025-06-03 13:39:09 -05:00
Matt Walsh
e01edc6972 Merge gordonthree-main to main 2025-06-03 13:38:27 -05:00
gordonthree
ab0249e6eb forgot to replace hypen with underscore 2025-06-03 12:46:52 -04:00
gordonthree
c4c85b3b7b shortened example content, revised comments to align with existing permalink example 2025-06-03 12:01:59 -04:00
Matt Walsh
e954033979 reorganize and update readme 2025-06-03 09:39:57 -05:00
gordonthree
ba39af9126 added ports info to the compose example 2025-06-03 09:42:51 -04:00
gordonthree
a814fde5b5 Added docker-compose example 2025-06-03 09:35:38 -04:00
Matt Walsh
3f5f78eddf update dependencies 2025-06-03 07:36:17 -05:00
Matt Walsh
974a061b44 clean up paths for server and server-dist, remove cors #96 2025-06-02 20:59:35 -05:00
Matt Walsh
dc64e4bd7f 5.21.13 2025-06-02 20:42:13 -05:00
Matt Walsh
776148fa6b handle haze-n regional map icon 2025-06-02 20:42:03 -05:00
Matt Walsh
7c50f5f1d7 issues changing locations 2025-06-02 15:57:58 -05:00
Matt Walsh
4bf3f4d1e0 scroll triggers properly on red background 2025-06-02 14:48:53 -05:00
Matt Walsh
46da573715 hazard scroll working, needs styling #92 2025-06-01 23:25:07 -05:00
Matt Walsh
69c050eb8f 5.21.12 2025-06-01 21:09:15 -05:00
Matt Walsh
a3e142dade Merge pull request #95 from kirbysayshi/ksh/disable-radar-for-bots
Expand radar feature disabling to cover all iOS browsers and the Messages crawler
2025-06-01 21:03:42 -05:00
Andrew Petersen
28917489bb simplify check to cover other ios browsers too 2025-06-01 17:14:45 -04:00
Andrew Petersen
2365a4c0f7 prevent loading radar for iMessages web preview bot 2025-06-01 17:05:18 -04:00
Matt Walsh
8afef77ea5 5.21.11 2025-06-01 13:38:21 -05:00
Matt Walsh
8f70ee87c5 add smoke to large icon set close #91 2025-06-01 13:37:32 -05:00
Matt Walsh
4e7429bfba 5.21.10 2025-05-31 13:30:30 -05:00
Matt Walsh
c5ffe1542a TEMPORARY don't allow radar on safari on ios 2025-05-31 13:30:21 -05:00
Matt Walsh
5364855c58 5.21.9 2025-05-31 13:20:45 -05:00
Matt Walsh
18efd810bd permalink coloring, readme additions close #88 2025-05-31 13:20:35 -05:00
Matt Walsh
68a6bae3a7 5.21.8 2025-05-30 09:06:45 -05:00
Matt Walsh
5f0f0d9000 Correct smoke forecast text on extended forecast #91 2025-05-30 09:06:40 -05:00
Matt Walsh
9d9cf4b0f3 5.21.7 2025-05-30 07:58:59 -05:00
Matt Walsh
9e500143c0 load radar workers later in the startup process 2025-05-30 07:57:28 -05:00
Matt Walsh
71da682660 5.21.6 2025-05-29 23:08:11 -05:00
Matt Walsh
1b9a1dcb22 don't clobber browser alt-left/right shortcuts 2025-05-29 23:08:04 -05:00
Matt Walsh
095761ee81 5.21.5 2025-05-29 21:04:51 -05:00
Matt Walsh
21e528aaa3 fix for kiosk mode scrollbars #86 2025-05-29 21:03:48 -05:00
Matt Walsh
a92c632937 5.21.4 2025-05-29 20:24:08 -05:00
Matt Walsh
6073fd1733 better missing data handling for current conditions #87 2025-05-29 20:23:55 -05:00
Matt Walsh
5da8185633 Merge pull request #89 from kevinastone/patch-2
Gracefully shutdown on both SIGINT + SIGTERM
2025-05-29 20:02:43 -05:00
Kevin Stone
cf5c818ee3 Gracefully shutdown on both SIGINT + SIGTERM
Most service managers (systemd, docker, etc) use SIGTERM as the shutdown signal by default rather than SIGINT (which is used for interactive CTRL-C).
2025-05-29 17:37:40 -07:00
Matt Walsh
97cec114f6 Merge branch 'main' of github.com:netbymatt/ws4kp 2025-05-29 17:03:55 -05:00
Matt Walsh
7efd2e8db7 add scanlines 2025-05-29 17:03:50 -05:00
Matt Walsh
8c28f41d54 Merge pull request #85 from dylan-park/readme-patch-1
update local run instructions in README
2025-05-29 14:45:07 -05:00
Dylan Park
e9d603fbfc update local run instructions in README 2025-05-29 14:24:05 -05:00
Matt Walsh
32aa43c5b1 update user-agent header, now allowed in some browsers 2025-05-29 14:18:49 -05:00
Matt Walsh
dbc56f014a 5.21.3 2025-05-29 13:34:22 -05:00
Matt Walsh
3161a03797 fix for stale forecast data on regional forecasts #84 2025-05-29 13:32:47 -05:00
Matt Walsh
205fa77f51 5.21.2 2025-05-29 08:36:21 -05:00
Matt Walsh
28bb8f2e2a scale nav buttons on narrow screens 2025-05-29 08:36:13 -05:00
Matt Walsh
cf9a99a6ca update dependencies 2025-05-29 08:31:03 -05:00
Matt Walsh
a83afa71cd code cleanup 2025-05-29 08:30:01 -05:00
Matt Walsh
74f1abd6f8 switch to zoom scaling 2025-05-27 16:33:03 -05:00
Matt Walsh
1bd45bdeeb 5.21.1 2025-05-25 19:39:40 -05:00
Matt Walsh
232061b4d8 revert to ttf font to fix layout problems close #82 2025-05-25 19:39:25 -05:00
Matt Walsh
10d10ffbfb Merge branch 'radar-rendering' 2025-05-25 19:18:32 -05:00
Matt Walsh
25ac2059a6 remove debug timing 2025-05-25 19:11:52 -05:00
Matt Walsh
25626a98c9 5.21.0 2025-05-25 15:18:32 -05:00
Matt Walsh
002e037bbd optimized radar image merging 2025-05-25 15:17:56 -05:00
Matt Walsh
8d20f7672c radar processed in web worker 2025-05-24 16:36:41 -05:00
Matt Walsh
5567fe37a6 add instrumentation 2025-05-24 09:22:23 -05:00
Matt Walsh
2dcc33f210 change to offscreen canvas 2025-05-23 23:13:50 -05:00
Matt Walsh
8f86f80eb5 5.20.5 2025-05-23 22:15:02 -05:00
Matt Walsh
1609ab3d38 radar host overrides 2025-05-23 22:14:48 -05:00
Matt Walsh
0be23ee988 radar speed improvements 2025-05-23 21:18:54 -05:00
Matt Walsh
a3ea2c3708 5.20.4 2025-05-23 16:08:19 -05:00
Matt Walsh
09fb698350 locally limit the number of alerts/hazards 2025-05-23 16:08:11 -05:00
Matt Walsh
6f6efe801c 5.20.3 2025-05-23 15:47:27 -05:00
Matt Walsh
bc77a1891c remove limit for alert endpoint due to recent api change 2025-05-23 15:43:58 -05:00
Matt Walsh
4666878250 5.20.2 2025-05-21 13:55:39 -05:00
Matt Walsh
5813dd9a92 title overflow cleanup 2025-05-21 13:55:27 -05:00
Matt Walsh
8cb8873760 add hooks for geoip lookup 2025-05-21 13:49:49 -05:00
Matt Walsh
323c175936 css cleanup 2025-05-20 22:19:46 -05:00
457 changed files with 46123 additions and 35387 deletions

View File

@@ -1,2 +0,0 @@
*.min.*
server/scripts/vendor/*

View File

@@ -1,91 +0,0 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"extends": [
"airbnb-base"
],
"globals": {
"TravelCities": "readonly",
"RegionalCities": "readonly",
"StationInfo": "readonly",
"SunCalc": "readonly",
"NoSleep": "readonly"
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [],
"rules": {
"indent": [
"error",
"tab",
{
"SwitchCase": 1
}
],
"no-tabs": 0,
"no-console": 0,
"max-len": 0,
"no-use-before-define": [
"error",
{
"variables": false
}
],
"no-param-reassign": [
"error",
{
"props": false
}
],
"no-mixed-operators": [
"error",
{
"groups": [
[
"&",
"|",
"^",
"~",
"<<",
">>",
">>>"
],
[
"==",
"!=",
"===",
"!==",
">",
">=",
"<",
"<="
],
[
"&&",
"||"
],
[
"in",
"instanceof"
]
],
"allowSamePrecedence": true
}
],
"import/extensions": [
"error",
{
"mjs": "always",
"json": "always"
}
]
},
"ignorePatterns": [
"*.min.js"
]
}

View File

@@ -11,4 +11,4 @@ Please do not report issues with api.weather.gov being down. It's a new service
Please include:
* Web browser and OS
* Location for which you are viewing a forecast
* Headend Information text block from the very bottom of the web page

13
.github/ISSUE_TEMPLATE/naming _issue.md vendored Normal file
View File

@@ -0,0 +1,13 @@
---
name: Naming issue
about: A city, airport or other location is not named correctly
title: 'Name Issue: '
labels: naming
assignees: ''
---
This form is not for reporting a location that you can not find from the search box.
Use this form to help us rename airports, points of interest and other data provided from the API (rarely updated) to a better name. For example the airport in Broomfield colorado was renamed from "Jeffco" in the API to "Rocky Mountain Metro" it's new name.
You can also make a pull request on the `[station-overrides.mjs](https://github.com/netbymatt/ws4kp/blob/main/datagenerators/stations-states.mjs)` file which includes instructions on how to make the change directly. This is the preferred method.

View File

@@ -1,5 +1,12 @@
name: build-docker
on: push
on:
push:
branches:
- '**'
tags:
- 'v*.*.*'
- 'v*.*'
jobs:
build:
@@ -13,7 +20,7 @@ jobs:
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/netbymatt/ws4kp
@@ -27,23 +34,23 @@ jobs:
type=semver,pattern={{major}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
uses: docker/build-push-action@v6
with:
context: .
pull: true
push: ${{ github.ref == 'refs/heads/main' }}
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }}
platforms: linux/amd64,linux/arm64/v8
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

12
.vscode/launch.json vendored
View File

@@ -26,6 +26,18 @@
"skipFiles": [
"<node_internals>/**"
],
"args": [
"--use-cache"
],
"type": "node"
},
{
"name": "Data:stations-api",
"program": "${workspaceFolder}/datagenerators/stations.mjs",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
},
{

View File

@@ -17,7 +17,4 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript"
],
}
}

View File

@@ -1,10 +1,19 @@
FROM node:18-alpine
FROM node:24-alpine AS node-builder
WORKDIR /app
COPY package.json .
COPY package-lock.json .
RUN npm ci
COPY . .
CMD ["node", "index.mjs"]
RUN npm install
RUN npm run build
RUN rm dist/playlist.json
FROM nginx:alpine
COPY static-env-handler.sh /docker-entrypoint.d/01-static-env-handler.sh
RUN chmod +x /docker-entrypoint.d/01-static-env-handler.sh
COPY --from=node-builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
CMD ["nginx", "-g", "daemon off;"]

13
Dockerfile.server Normal file
View File

@@ -0,0 +1,13 @@
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build
EXPOSE 8080
ENV DIST=1
CMD ["npm", "start"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2024 Matt Walsh
Copyright (c) 2020-2025 Matt Walsh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

482
README.md
View File

@@ -4,7 +4,356 @@ A live version of this project is available at https://weatherstar.netbymatt.com
## About
This project aims to bring back the feel of the 90's with a weather forecast that has the look and feel of The Weather Channel at that time but available in a modern way. This is by no means intended to be a perfect emulation of the WeatherStar 4000, the hardware that produced those wonderful blue and orange graphics you saw during the local forecast on The Weather Channel. If you would like a much more accurate project please see the [WS4000 Simulator](http://www.taiganet.com/). Instead, this project intends to create a simple to use interface with minimal configuration fuss. Some changes have been made to the screens available because either more or less forecast information is available today than was in the 90's. Most of these changes are captured in sections below.
This project aims to bring back the feel of the 90s with a weather forecast that has the look and feel of The Weather Channel at that time but available in a modern way. This is by no means intended to be a perfect emulation of the WeatherStar 4000, the hardware that produced those wonderful blue and orange graphics you saw during the local forecast on The Weather Channel. If you would like a much more accurate project please see the [WS4000 Simulator](http://www.taiganet.com/). Instead, this project intends to create a simple to use interface with minimal configuration fuss. Some changes have been made to the screens available because either more or less forecast information is available today than was in the 90s. Most of these changes are captured in sections below.
## What's your motivation
Nostalgia. And I enjoy following the weather, especially severe storms.
It's also a creative outlet for me and keeps my programming skills honed for when I need them for my day job.
### Included technology
I've kept this open source, well commented, and made it as library-free as possible to help others interested in programming be able to jump right in and start working with the code.
From a learning standpoint, this codebase make use of a lot of different methods and technologies common on the internet including:
* The https://api.weather.gov REST API. ([documentation](https://www.weather.gov/documentation/services-web-api)).
* ES 6 functionality
* Arrow functions
* Promises
* Async/await and parallel loading of all forecast resources
* Classes and extensions
* Javascript modules
* Separation between API code and user interface code
* Use of a modern date parsing library [luxon](https://moment.github.io/luxon/)
* Practical API rates and static asset caching
* Very straight-forward hand written HTML
* Build system integration (Gulp, Webpack) to reduce the number of scripts that need to be loaded
* Hand written CSS made easier to mange with SASS
* A linting library to keep code style consistent
## Quck Start
Ensure you have Node installed.
```bash
git clone https://github.com/netbymatt/ws4kp.git
cd ws4kp
npm install
npm start
```
Open your browser and navigate to https://localhost:8080
## Does WeatherStar 4000+ work outside of the USA?
This project is tightly coupled to [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api), which is exclusive to the United States. Using NOAA's Weather API is a crucial requirement to provide an authentic WeatherStar 4000+ experience.
If you would like to display weather information for international locations (outside of the USA), please checkout a fork of this project created by [@mwood77](https://github.com/mwood77):
- [`ws4kp-international`](https://github.com/mwood77/ws4kp-international)
## Deployment Modes
WeatherStar 4000+ supports two deployment modes:
### Server Deployment (Recommended)
* Includes Node.js server with caching proxy for better performance (especially when running on a local server for multiple clients)
* Server-side request deduplication and caching
* Weather API observability and logging
* Used by: `npm start`, `DIST=1 npm start`, and `Dockerfile.server`
### Static Deployment
* Pure client-side deployment using nginx to serve static files
* All API requests are made directly from each browser to the weather services
* Browser-based caching
* Used by: static file hosting and default `Dockerfile`
## Other methods to run Ws4kp
### Development Mode (individual JS files, easier debugging)
```bash
npm start
```
### Development Mode without proxy caching
```bash
STATIC=1 npm start
```
### Production Mode (minified/concatenated JS, faster loading)
```bash
npm run build
DIST=1 npm start
```
### Production Mode without proxy caching (simulates static Docker deployment)
```bash
npm run build
STATIC=1 DIST=1 npm start
```
For all modes, access WeatherStar by going to: http://localhost:8080/
### Key Differences
**Development Mode (`npm start`):**
- Uses individual JavaScript module files served directly
- Easier debugging with source maps and readable code
- Slower initial load (many HTTP requests for individual files)
- Live file watching and faster development iteration
**Production Mode (`DIST=1 npm start`):**
- Uses minified and concatenated JavaScript bundles
- Faster initial load (fewer HTTP requests, smaller file sizes)
- Optimized for performance with multiple clients
- Requires `npm run build` to generate optimized files
### Docker Deployments
To run via Docker using a "static deployment" where everything happens in the browser (no server component, like STATIC=1):
```bash
docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
```
To run via Docker using a "server deployment" with a caching proxy server for multi-client performance and enhanced observability (like `npm run build; DIST=1 npm start`):
```bash
docker build -f Dockerfile.server -t ws4kp-server .
docker run -p 8080:8080 ws4kp-server
```
To run via Docker Compose (shown here in static deployment mode):
```yaml
---
services:
ws4kp:
image: ghcr.io/netbymatt/ws4kp
container_name: ws4kp
environment:
# Each argument in the permalink URL can become an environment variable on the Docker host by adding WSQS_
# Following the "Sharing a Permalink" example below, here are a few environment variables defined. Visit that section for a
# more complete list of configuration options.
- WSQS_latLonQuery="Orlando International Airport Orlando FL USA"
- WSQS_hazards_checkbox=false
- WSQS_current_weather_checkbox=true
ports:
- 8080:8080 # change the first 8080 to meet your local network needs
restart: unless-stopped
```
### Serving a static app
There are several ways to deploy WeatherStar as a static app that runs entirely in the browser:
**Manual static hosting (Apache, nginx, CDN, etc.):**
Build static distribution files for upload to any web server:
```bash
npm run build
```
The resulting files in `/dist` can be uploaded to any web server; no server-side scripting is required.
**Docker static deployment:**
The default Docker image uses nginx to serve pre-built static files:
```bash
docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
```
**Node.js in static mode:**
Use the Node.js server as a static file host without the caching proxy:
```bash
STATIC=1 npm start # Use Express to serve development files
STATIC=1 DIST=1 npm start # Use Express to serve (minimized) production files
```
## What's different
I've made several changes to this Weather Star 4000 simulation compared to the original hardware unit and the code that this was forked from.
* Radar displays the timestamp of the image.
* A new hour-by-hour graph of the temperature, cloud cover and precipitation chances for the next 24 hours.
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast. (off by default because it duplicates the hourly graph)
* The SPC Outlook is shown in the style of the old air quality screen. This shows the probability of severe weather over the next 3 days at your location. SPC outlook only displays if you're within one of the highlight areas over the next 3 day. You can view the [maps](https://www.weather.gov/crh/outlooks) and pick a location within one of the risk categories to see if the screen is working for you.
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90s.
* The original music has been replaced. More info in [Music](#music).
* Marine forecast (tides) is not available as it is not reliably part of the new API.
* "Flavors" are not present in this simulation. Flavors refer to the order of the weather information that was shown on the original units. Instead, the order of the displays has been fixed and a checkboxes can be used to turn on and off individual displays. The travel forecast has been defaulted to off so only local information shows for new users.
## Sharing a permalink (bookmarking)
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it, click the "Copy Permalink" (or get "Get Permalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
Your permalink will be very long. Here is an example for the Orlando International Airport:
```
https://weatherstar.netbymatt.com/?hazards-checkbox=false&current-weather-checkbox=true&latest-observations-checkbox=true&hourly-checkbox=false&hourly-graph-checkbox=true&travel-checkbox=false&regional-forecast-checkbox=true&local-forecast-checkbox=true&extended-forecast-checkbox=true&almanac-checkbox=false&spc-outlook-checkbox=true&radar-checkbox=true&settings-wide-checkbox=false&settings-kiosk-checkbox=false&settings-scanLines-checkbox=false&settings-speed-select=1.00&settings-units-select=us&latLonQuery=Orlando+International+Airport%2C+Orlando%2C+FL%2C+USA&latLon=%7B%22lat%22%3A28.431%2C%22lon%22%3A-81.3076%7D
```
You can also build your own permalink. Any omitted settings will be filled with defaults. Here are a few examples:
```
https://weatherstar.netbymatt.com/?latLonQuery=Orlando+International+Airport
https://weatherstar.netbymatt.com/?kiosk=true
https://weatherstar.netbymatt.com/?settings-units-select=metric
```
### Kiosk mode
Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified. A separate full-screen icon is available in the tool bar to go full-screen on a laptop or mobile browser.
It's also possible to enter kiosk mode using a permalink. First generate a [Permalink](#sharing-a-permalink-bookmarking), then to the end of it add `&kiosk=true`. Opening this link will load all of the selected displays included in the Permalink, enter kiosk mode immediately upon loading and start playing the forecast.
### Default query string parameters (environment variables)
When serving this via the built-in Express server, it's possible to define environment variables that direct the user to a default set of parameters (like the [Permalink](#sharing-a-permalink-bookmarking) above). If a user requests the root page at `http://localhost:8080/` the query string provided by environment variables will be appended to the url thus providing a default configuration.
Environment variables can be added to the command line as usual, or via a .env file which is parsed with [dotenv](https://github.com/motdotla/dotenv). Both methods have the same effect.
Environment variables that are to be added to the default query string are prefixed with `WSQS_` and then use the same key/value pairs generated by the [Permalink](#sharing-a-permalink-bookmarking) above, with the `- (dash)` character replaced by an `_ (underscore)`. For example, if you wanted to turn the travel forecast on, you would find `travel-checkbox=true` in the permalink, its matching environment variable becomes `WSQS_travel_checkbox=true`.
When using the Docker container, these environment variables are read on container start-up to generate the static redirect HTML.
## Settings
**Speed:** Controls the playback speed multiplier of the displays, from "Very Fast" (1.5x) to "Very Slow" (0.5x) with "Normal" being 1x
**Widescreen:** Stretches the background to 16:9 to avoid "pillarboxing" on modern displays
**Kiosk:** Immediately activates kiosk mode, which hides all settings. Exit by refreshing the page or using `Ctrl-K`. (Kiosk mode is similar to clicking the "Fullscreen" icon, but scales to the current browser viewport instead of activating the browser's actual "Fullscreen" mode.)
**Sticky Kiosk:** When enabled, stores the kiosk mode preference in local storage so the page automatically enters kiosk mode (maximizing the size of the main weather display without any settings) on subsequent visits. This feature is designed primarily for **iPhone and iPad users** who want to create a Home Screen app experience, since Mobile Safari doesn't support PWA installation via manifest.json or the Fullscreen API:
**For iOS/iPadOS (Mobile Safari):**
1. Tap the _Share_ icon and choose **Add to Home Screen**
2. Adjust the name as desired and tap **Add**
3. Launch the newly-created Home Screen shortcut
4. Configure all settings
5. Tap to enable **Sticky Kiosk**
6. _Make sure everything is configured exactly like you want it!_
7. Tap **Kiosk**
**For Android and Desktop browsers:** The included `manifest.json` file enables PWA (Progressive Web App) installation. To get the best app-like experience:
1. Configure all your settings first (ignore the "Kiosk" and "Sticky Kiosk" settings)
2. Create a permalink using the "Copy Permalink" feature and manually add `&kiosk=true` to the end
3. Open the edited permalink URL in your browser
4. Look for browser prompts to "Install" or "Add to Home Screen" from the kiosk-enabled URL
5. The PWA will launch directly into kiosk mode (without forcing kiosk mode when accessed from the browser)
For temporary fullscreen during regular browsing, use the fullscreen button in the toolbar.
**Important Notes:**
* **iOS/iPadOS limitations**: Mobile Safari strips all URL parameters when adding to Home Screen and runs shortcuts in an isolated environment with separate storage from the main Safari app
* After creating a Home Screen app on iOS or iPadOS and activating Kiosk mode, the only way to change settings is to delete the Home Screen shortcut and recreate it
* In situations where you _can_ edit a shortcut's URL, you can forcibly remove a "sticky" kiosk setting by adding `&kiosk=false` to the URL (or simply press `Ctrl-K` to exit kiosk mode if a keyboard is available)
**Scan Lines:** Enables a retro-style scan line effect
**Scan Lines Style:** Override the "auto" setting in case you prefer a different scale factor than what the automatic heuristics select for your browser and display
**Units:** Switches between US and metric units. (Note that some text-based products from the National Weather Service APIs contain embedded units that are not converted.)
**Volume:** Controls the audio level when music is enabled
## Music
The WeatherStar had wonderful background music from the smooth jazz and new age genres by artists of the time. Lists of the music that played are available by searching online, but it's all copyrighted music and would be difficult to provide as part of this repository.
I've used AI tools to create WeatherStar-inspired music tracks that are unencumbered by copyright and are included in this repo. To keep the size down, I've only included 4 tracks. Additional tracks are in a companion repository [ws4kp-music](https://github.com/netbymatt/ws4kp-music).
If you're looking for the original music that played during forecasts [TWCClassics](https://twcclassics.com/audio/) has thorough documentation of playlists.
### Customizing the music
WeatherStar 4000+ supports background music during forecast playback. The music behavior depends on how you deploy the application:
#### Express server modes (`npm start`, `DIST=1 npm start`, or `Dockerfile.server`)
When running with Node.js, the server generates a `playlist.json` file by scanning the `./server/music` directory for `.mp3` files. If no files are found in `./server/music`, it falls back to scanning `./server/music/default/`. The playlist is served dynamically at the `/playlist.json` endpoint.
**Adding your own music:** Place `.mp3` files in `./server/music/`
**Docker server example:**
```bash
docker build -f Dockerfile.server -t ws4kp-server .
docker run -p 8080:8080 -v /path/to/local/music:/app/server/music ws4kp-server
```
#### Static hosting modes (default `Dockerfile`, nginx, Apache, etc.)
When hosting static files, there are two scenarios:
**Static Docker deployment:** The build process creates a `playlist.json` file with default tracks, but the Docker image _intentionally_ removes it to force browser-based directory scanning. The browser attempts to fetch `playlist.json`, receives a 404 response with the `X-Weatherstar` header, which causes it to fallback to scanning the `music/` directory.
**Manual static hosting:** If you build and upload the files yourself (`npm run build`), `playlist.json` will contain the default tracks unless you customize `./server/music/` before building.
For directory scanning to work properly:
* Your web server must generate directory listings for the `music/` path
* Your web server must set the `X-Weatherstar: true` header (the provided nginx configuration does this)
**Adding your own music:** Place `.mp3` files in `music/` (or bind mount to `/usr/share/nginx/html/music` for Docker)
**Docker static example:**
```bash
docker run -p 8080:8080 -v /path/to/local/music:/usr/share/nginx/html/music ghcr.io/netbymatt/ws4kp
```
Subdirectories will not be scanned. When WeatherStar loads in the browser, it randomizes the track order and reshuffles on each loop through the playlist.
### Music doesn't auto play
Ws4kp is muted by default, but if it was unmuted on the last visit it is coded to try and auto play music on subsequent visits. But, it's considered bad form to have a web site play music automatically on load, and I fully agree with this. [Chrome](https://developer.chrome.com/blog/autoplay/#media_engagement_index) and [Firefox](https://hacks.mozilla.org/2019/02/firefox-66-to-block-automatically-playing-audible-video-and-audio/) have extensive details on how and when auto play is allowed.
Chrome seems to be more lenient on auto play and will eventually let a site auto-play music if you're visited it enough recently and manually clicked to start playing music on each visit. It also has a flag you can add to the command line when launching Chrome: `chrome.exe --autoplay-policy=no-user-gesture-required`. This is the best solution when using Kiosk-style setup.
If you're unable to pre-set the play state before entering kiosk mode (such as with a home dashboard implementation) you can add the query string value below to the url. The browser will still follow the auto play rules outlined above.
```
?settings-mediaPlaying-boolean=true
```
## Community Notes
Thanks to the WeatherStar+ community for providing these discussions to further extend your retro forecasts!
* [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948)
* [Weather like it's 1999](https://blog.scottlabs.io/2024/02/weather-like-its-1999/) Raspberry pi, streaming, music and CRT all combined into a complete solution.
* [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg.
* [SSL Certificates](https://github.com/netbymatt/ws4kp/issues/135) Discussion about how to host with an SSL certificate (enables geolocation).
* [Changing playlists](https://github.com/netbymatt/ws4kp/issues/138) Possible ways to automatically change the playlist on a schedule.
* [Customize Travel Forecast Cities](https://github.com/netbymatt/ws4kp/issues/146#issuecomment-3363940202)
## Customization
A hook is provided as `server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. A sample file is provided at `server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it.
When using Docker:
* **Static deployment**: Mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js`
* **Server deployment**: Mount your `custom.js` file to `/app/server/scripts/custom.js`
### RSS feeds and custom scroll
If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, or show headlines from an rss feed turn on the setting for `Enable RSS Feed/Text` and then enter a URL or text in the resulting text box. Then press set.
## Issue reporting and feature requests
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. I've also observed that the API can go down on a regional basis (based on NWS office locations). This means that you may have problems getting data for, say, Chicago right now, but Dallas and others are working just fine.
Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 4000, it's a best effort that fits within what's available from the API and within a web browser.
Note: not all units are converted to metric, if selected. Some text-based products such as warnings are simple text strings provided from the national weather service and thus have baked-in units such as "gusts up to 60 mph." These values will not be converted.
## The full moon icon is broken
This is a known problem with the Ws4kp as it ages. It was a problem with the [actual Weatherstar hardware](https://youtu.be/rcUwlZ4pqh0?feature=shared&t=116) as well.
## Related Projects
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)
## Use
Linking directly to the live web site at https://weatherstar.netbymatt.com is encouraged. As is using the live site for digital signage, home dashboards, streaming and public display. Please note the disclaimer below.
## Acknowledgements
@@ -15,139 +364,10 @@ This project is based on the work of [Mike Battaglia](https://github.com/vbguyny
* A [font](https://twcclassics.com/downloads.html) set used on the original WeatherStar 4000
* [Icon](https://twcclassics.com/downloads.html) sets
* Countless photos and videos of WeatherStar 4000 forecasts used as references.
## Does WeatherStar 4000+ work outside of the USA?
This project is tightly coupled to [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api), which is exclusive to the United States. Using NOAA's Weather API is a crucial requirement to provide an authentic WeatherStar 4000+ experience.
If you would like to display weather information for international locations (outside of the USA), please checkout a fork of this project created by [@mwood77](https://github.com/mwood77):
- [`ws4kp-international`](https://github.com/mwood77/ws4kp-international)
## Run Your WeatherStar
There are a lot of CORS considerations and issues with api.weather.gov that are easiest to deal with by running a local server to see this in action (or use the live link above). You'll need Node.js >12.0 to run the local server.
To run via Node locally:
```
git clone https://github.com/netbymatt/ws4kp.git
cd ws4kp
npm i
node index.js
```
To run via Docker:
```
docker run -p 8080:8080 ghcr.io/netbymatt/ws4kp
```
Open your web browser: http://localhost:8080/
## Updates in 5.0
The change to 5.0 changes from drawing the weather graphics on canvas elements and instead uses HTML and CSS to style all of the weather graphics. A lot of other changes and fixes were implemented at the same time.
* Replace all canvas elements with HTML and CSS
* City and airport names are better parsed to fit the available space
* Remove the dependency on libgif-js
* Use browser for text wrapping where necessary
* Some new weather icons
* Refresh only on slideshow repeat
* Removed Almanac 30-day outlook
* Fixed startup issue when current conditions are unavailable
## Why the fork?
The fork is a result of wanting a more manageable, modern code base to work with. Part of it is an exercise in my education in JavaScript. There are several technical changes that were made behind the scenes.
* Make use of the new API available at https://api.weather.gov ([documentation](https://www.weather.gov/documentation/services-web-api)). This caused the removal of some of the original WeatherStar 4000 displays, and allowed for new displays to be created.
* Changed code to make extensive use of ES6 functionality including:
* Arrow functions
* Promises
* Async/await and parallel loading of all forecast resources
* Classes
* Common code base for each display through use of classes
* Separation between weather display code and user interface
* Use of a modern date parsing library [luxon](https://moment.github.io/luxon/)
* Attempt to remove the need for a local server to bypass [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) issues with the various APIs used. This is almost workable but there are still some minor CORS issues with https://api.weather.gov.
* The necessary CORS pass through URLs have been rewritten so they can be deployed on Node.js using the included server or through S3/Cloudfront in a serverless environment.
* Proper settings for static resource caching
* Build system integration to reduce the number of scripts that need to be loaded
## What's different
I've made several changes to this Weather Star 4000 simulation compared to the original hardware unit and the code that this was forked from.
* Radar displays the timestamp of the image.
* A new hour-by-hour graph of the temperature, cloud cover and precipitation chances for the next 24 hours.
* A new hourly forecast display for the next 24 hours is available, and is shown in the style of the travel cities forecast. (off by default because it duplicates the hourly graph)
* The "Local Forecast" and "Extended Forecast" provide several additional days of information compared to the original format in the 90's.
* Narration was removed. In the original code narration made use of the computer's local text-to-speech engine which didn't sound great.
* Music was removed. I don't want to deal with copyright issues and hosting MP3s. If you're looking for the music that played during forecasts please visit [TWCClassics](https://twcclassics.com/audio/).
* Marine forecast (tides) is not available as it is not part of the new API.
* The nearby cities displayed on screens such as "Latest Observations" and "Regional Forecast" are likely not the same as they were in the 90's. The weather monitoring equipment at these stations move over time for one reason or another, and coming up with a simple formulaic way of finding nearby stations is sufficient to give the same look-and-feel as the original.
* "Flavors" are not present in this simulation. Flavors refer to the order of the weather information that was shown on the original units. Instead, the order of the displays has been fixed and a checkboxes can be used to turn on and off individual displays. The travel forecast has been defaulted to off so only local information shows for new users.
## Sharing a permalink (bookmarking)
Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it click the "Copy Permalink" (or get "Get Parmalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks.
## Kiosk mode
Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified.
It's also possible to enter kiosk mode using a permalink. First generate a [Permalink](#sharing-a-permalink-bookmarking), then to the end of it add `&kiosk=true`. Opening this link will load all of the selected displays included in the Permalink, enter kiosk mode immediately upon loading and start playing the forecast.
## Default query string paramaters (environment variables)
When serving this via the built-in Express server, it's possible to define environment variables that direct the user to a default set of paramaters (like the [Permalink](#sharing-a-permalink-bookmarking) above). If a user requests the root page at `http://localhost:8080/` the query string provided by environment variables will be appended to the url thus providing a default configuration.
Environment variables can be added to the command line as usual, or via a .env file which is parsed with [dotenv](https://github.com/motdotla/dotenv). Both methods have the same effect.
Environment variables that are to be added to the default query string are prefixed with `WSQS_` and then use the same key/value pairs generated by the [Permalink](#sharing-a-permalink-bookmarking) above, with the `- (dash)` character replaced by an `_ (underscore)`. For example, if you wanted to turn the travel forecast on, you would find `travel-checkbox=true` in the permalink, it's matching environment variable becomes `WSQS_travel_checkbox=true`.
## Serving static files
The app can be served as a static set of files on any web server. Run the provided gulp task to create a set of static distribution files:
```
npm run buildDist
```
The resulting files will be in the /dist folder in the root of the project. These can then be uploaded to a web server for hosting, no server-side scripting is required.
## Music
The WeatherStar had wonderful background music from the smooth jazz and new age genres by artists of the time. Lists of the music that played are available by searching online, but it's all copyrighted music and would be difficult to provide as part of this repository.
I've used AI tools to create WeatherStar-inspired music tracks that are unencumbered by copyright and are included in this repo. Too keep the size down, I've only included 4 tracks. Additional tracks are in a companion repository [ws4kp-music](https://github.com/netbymatt/ws4kp-music).
### Customizing the music
Placing .mp3 files in the `/server/music` folder will override the default music included in the repo. Subdirectories will not be scanned. When weatherstar loads in the browser it will load a list if available files and randomize the order when it starts playing. On each loop through the available tracks the order will again be shuffled. If you're using the static files method to host your WeatherStar music is located in `/music`.
If using docker, you must pass a local accessible folder to the container in the `/app/server/music` directory.
```
docker run -p 8080:8080 -v /path/to/local/music:/app/server/music ghcr.io/netbymatt/ws4kp
```
### Music doesn't auto play
Ws4kp is muted by default, but if it was unmuted on the last visit it is coded to try and auto play music on subsequent visits. But, it's considered bad form to have a web site play music automatically on load, and I fully agree with this. [Chrome](https://developer.chrome.com/blog/autoplay/#media_engagement_index) and [Firefox](https://hacks.mozilla.org/2019/02/firefox-66-to-block-automatically-playing-audible-video-and-audio/) have extensive details on how and when auto play is allowed.
Chrome seems to be more lenient on auto play and will eventually let a site auto-play music if you're visited it enough recently and manually clicked to start playing music on each visit. It also has a flag you can add to the command line when launching Chrome: `chrome.exe --autoplay-policy=no-user-gesture-required`. This is the best solution when using Kiosk-style setup.
## Community Notes
Thanks to the WeatherStar community for providing these discussions to further extend your retro forecasts!
* [Stream as FFMPEG](https://github.com/netbymatt/ws4kp/issues/37#issuecomment-2008491948)
* [Weather like it's 1999](https://blog.scottlabs.io/2024/02/weather-like-its-1999/) Raspberry pi, streaming, music and CRT all combined into a complete solution.
* [ws4channels](https://github.com/rice9797/ws4channels) A Dockerized Node.js application to stream WeatherStar 4000 data into Channels DVR using Puppeteer and FFmpeg.
## Customization
A hook is provided as `/server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. An sample file is provided at `/server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it.
## Issue reporting and feature requests
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 4000, it's a best effort that fits within what's available from the API and within a web browser.
Note: not all units are converted to metric, if selected. Some text-based products such as warnings are simple text strings provided from the national weather service and thus have baked-in units such as "gusts up to 60 mph." These values will not be converted.
## Related Projects
Not retro enough? Try the [Weatherstar 3000+](https://github.com/netbymatt/ws3kp)
* The growing list of contributors to this repository
## Disclaimer
This web site should NOT be used in life threatening weather situations, or be relied on to inform the public of such situations. The Internet is an unreliable network subject to server and network outages and by nature is not suitable for such mission critical use. If you require such access to NWS data, please consider one of their subscription services. The authors of this web site shall not be held liable in the event of injury, death or property damage that occur as a result of disregarding this warning.
The WeatherSTAR 4000 unit and technology is owned by The Weather Channel. This web site is a free, non-profit work by fans. All of the back ground graphics of this web site were created from scratch. The icons were created by Charles Abel and Nick Smith (http://twcclassics.com/downloads/icons.html) as well as by Malek Masoud. The fonts were originally created by Nick Smith (http://twcclassics.com/downloads/fonts.html).

View File

@@ -1,45 +0,0 @@
// pass through api requests
// http(s) modules
import https from 'https';
// url parsing
import queryString from 'querystring';
// return an express router
const cors = (req, res) => {
// add out-going headers
const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
headers.accept = req.headers.accept;
// get query paramaters if the exist
const queryParams = Object.keys(req.query).reduce((acc, key) => {
// skip the paramater 'u'
if (key === 'u') return acc;
// add the paramter to the resulting object
acc[key] = req.query[key];
return acc;
}, {});
let query = queryString.encode(queryParams);
if (query.length > 0) query = `?${query}`;
// get the page
https.get(`https://api.weather.gov${req.path}${query}`, {
headers,
}, (getRes) => {
// pull some info
const { statusCode } = getRes;
// pass the status code through
res.status(statusCode);
// set headers
res.header('content-type', getRes.headers['content-type']);
// pipe to response
getRes.pipe(res);
}).on('error', (e) => {
console.error(e);
});
};
export default cors;

View File

@@ -1,46 +0,0 @@
// pass through api requests
// http(s) modules
import https from 'https';
// url parsing
import queryString from 'querystring';
// return an express router
const outlook = (req, res) => {
// add out-going headers
const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
headers.accept = req.headers.accept;
// get query paramaters if the exist
const queryParams = Object.keys(req.query).reduce((acc, key) => {
// skip the paramater 'u'
if (key === 'u') return acc;
// add the paramter to the resulting object
acc[key] = req.query[key];
return acc;
}, {});
let query = queryString.encode(queryParams);
if (query.length > 0) query = `?${query}`;
// get the page
https.get(`https://www.cpc.ncep.noaa.gov/${req.path}${query}`, {
headers,
}, (getRes) => {
// pull some info
const { statusCode } = getRes;
// pass the status code through
res.status(statusCode);
// set headers
res.header('content-type', getRes.headers['content-type']);
res.header('last-modified', getRes.headers['last-modified']);
// pipe to response
getRes.pipe(res);
}).on('error', (e) => {
console.error(e);
});
};
export default outlook;

View File

@@ -1,46 +0,0 @@
// pass through api requests
// http(s) modules
import https from 'https';
// url parsing
import queryString from 'querystring';
// return an express router
const radar = (req, res) => {
// add out-going headers
const headers = {};
headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)';
headers.accept = req.headers.accept;
// get query paramaters if the exist
const queryParams = Object.keys(req.query).reduce((acc, key) => {
// skip the paramater 'u'
if (key === 'u') return acc;
// add the paramter to the resulting object
acc[key] = req.query[key];
return acc;
}, {});
let query = queryString.encode(queryParams);
if (query.length > 0) query = `?${query}`;
// get the page
https.get(`https://radar.weather.gov${req.path}${query}`, {
headers,
}, (getRes) => {
// pull some info
const { statusCode } = getRes;
// pass the status code through
res.status(statusCode);
// set headers
res.header('content-type', getRes.headers['content-type']);
res.header('last-modified', getRes.headers['last-modified']);
// pipe to response
getRes.pipe(res);
}).on('error', (e) => {
console.error(e);
});
};
export default radar;

View File

@@ -84,8 +84,8 @@
"lat": 29.7633,
"lon": -95.3633,
"point": {
"x": 65,
"y": 97,
"x": 63,
"y": 95,
"wfo": "HGX"
}
},
@@ -230,7 +230,7 @@
}
},
{
"city": "Washington DC",
"city": "Washington",
"lat": 38.8951,
"lon": -77.0364,
"point": {
@@ -274,7 +274,7 @@
"lat": 61.2181,
"lon": -149.9003,
"point": {
"x": 125,
"x": 143,
"y": 236,
"wfo": "AER"
}
@@ -734,8 +734,8 @@
"lat": 42.9956,
"lon": -71.4548,
"point": {
"x": 42,
"y": 21,
"x": 38,
"y": 20,
"wfo": "GYX"
}
},
@@ -884,8 +884,8 @@
"lat": 43.6615,
"lon": -70.2553,
"point": {
"x": 76,
"y": 59,
"x": 72,
"y": 58,
"wfo": "GYX"
}
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -84,8 +84,8 @@
"Latitude": 29.7633,
"Longitude": -95.3633,
"point": {
"x": 65,
"y": 97,
"x": 63,
"y": 95,
"wfo": "HGX"
}
},

View File

@@ -115,7 +115,7 @@
"lon": -82.5329
},
{
"city": "Washington DC",
"city": "Washington",
"lat": 38.8951,
"lon": -77.0364
},

View File

@@ -0,0 +1,19 @@
// station overrides are used to change the data for a station that is provided by the api
// the most common use is to adjust the city (station name) for formatting or to update an outdated name
// a complete station object looks like this:
// {
// "id": "KMCO", // 4-letter station identifier and key for lookups
// "city": "Orlando International Airport", // name displayed for this station
// "state": "FL", // state
// "lat": 28.41826, // latitude of station
// "lon": -81.32413 // longitude of station
// }
// any or all of the data for a station can be overwritten, follow the existing override patterns below
const overrides = {
KBJC: {
city: 'Rocky Mountain Metro',
},
};
export default overrides;

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-loop-func */
// list all stations in a single file
// only find stations with 4 letter codes
@@ -5,63 +6,92 @@ import { writeFileSync } from 'fs';
import https from './https.mjs';
import states from './stations-states.mjs';
import chunk from './chunk.mjs';
import overrides from './stations-overrides.mjs';
import postProcessor from './stations-postprocessor.mjs';
// check for cached flag
const USE_CACHE = process.argv.includes('--use-cache');
// skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
// chunk the list of states
const chunkStates = chunk(states, 1);
const chunkStates = chunk(states, 3);
// store output
const output = {};
let completed = 0;
// process all chunks
for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i];
// loop through states
// get data from api if desired
if (!USE_CACHE) {
// process all chunks
for (let i = 0; i < chunkStates.length; i += 1) {
const stateChunk = chunkStates[i];
// loop through states
// eslint-disable-next-line no-await-in-loop
await Promise.allSettled(stateChunk.map(async (state) => {
try {
let stations;
let next = `https://api.weather.gov/stations?state=${state}`;
let round = 0;
do {
console.log(`Getting: ${state}-${round}`);
// get list and parse the JSON
// eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// add each resulting station to the output
stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier;
if (output[id]) {
console.log(`Duplicate station: ${state}-${id}`);
return;
}
output[id] = {
id,
city: station.properties.name,
state,
lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0],
};
});
next = stations?.pagination?.next;
round += 1;
// write the output
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(output, null, 2));
// eslint-disable-next-line no-await-in-loop
await Promise.allSettled(stateChunk.map(async (state) => {
try {
let stations;
let next = `https://api.weather.gov/stations?state=${state}`;
let round = 0;
do {
console.log(`Getting: ${state}-${round}`);
// get list and parse the JSON
// eslint-disable-next-line no-await-in-loop
const stationsRaw = await https(next);
stations = JSON.parse(stationsRaw);
// filter stations for 4 letter identifiers
const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/));
// filter against starting letter
const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
// add each resulting station to the output
stationsFiltered.forEach((station) => {
const id = station.properties.stationIdentifier;
if (output[id]) {
console.log(`Duplicate station: ${state}-${id}`);
return;
}
output[id] = {
id,
city: station.properties.name,
state,
lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0],
};
});
next = stations?.pagination?.next;
round += 1;
// write the output
writeFileSync('./datagenerators/output/stations-raw.json', JSON.stringify(output, null, 2));
}
while (next && stations.features.length > 0);
completed += 1;
console.log(`Complete: ${state} ${completed}/${states.length}`);
return true;
} catch {
console.error(`Unable to get state: ${state}`);
return false;
}
while (next && stations.features.length > 0);
console.log(`Complete: ${state}`);
return true;
} catch (e) {
console.error(`Unable to get state: ${state}`);
return false;
}
}));
}));
}
}
// run the post processor
// data is passed through the file stations-raw.json
const postProcessed = postProcessor();
// apply any overrides
Object.entries(overrides).forEach(([id, values]) => {
// check for existing value
if (postProcessed[id]) {
// apply the overrides
postProcessed[id] = {
...postProcessed[id],
...values,
};
}
});
// write final file to disk
writeFileSync('./datagenerators/output/stations.json', JSON.stringify(postProcessed, null, 2));

128
eslint.config.mjs Normal file
View File

@@ -0,0 +1,128 @@
import { FlatCompat } from '@eslint/eslintrc';
const compat = new FlatCompat({
});
export default [{
ignores: [
'*.min.*',
'server/scripts/vendor/*',
'dist/**/*',
],
},
...compat.config({
env: {
browser: true,
es6: true,
node: true,
},
extends: [
'airbnb-base',
],
globals: {
TravelCities: 'readonly',
RegionalCities: 'readonly',
StationInfo: 'readonly',
SunCalc: 'readonly',
NoSleep: 'readonly',
OVERRIDES: 'readonly',
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: [],
rules: {
indent: [
'error',
'tab',
{
SwitchCase: 1,
},
],
'no-tabs': 0,
'no-console': 0,
'max-len': 0,
'no-use-before-define': [
'error',
{
variables: false,
},
],
'no-param-reassign': [
'error',
{
props: false,
},
],
'no-mixed-operators': [
'error',
{
groups: [
[
'&',
'|',
'^',
'~',
'<<',
'>>',
'>>>',
],
[
'==',
'!=',
'===',
'!==',
'>',
'>=',
'<',
'<=',
],
[
'&&',
'||',
],
[
'in',
'instanceof',
],
],
allowSamePrecedence: true,
},
],
'no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'import/extensions': [
'error',
{
mjs: 'always',
json: 'always',
},
],
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
'eslint.config.*',
'**/*.config.*',
'**/*.test.*',
'**/*.spec.*',
'gulpfile.*',
'tests/**/*',
'gulp/**/*',
'datagenerators/**/*',
],
},
],
},
ignorePatterns: [
'*.min.js',
],
}),
];

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'dotenv/config';
import {
src, dest, series, parallel,
@@ -14,9 +13,10 @@ import webpack from 'webpack-stream';
import TerserPlugin from 'terser-webpack-plugin';
import { readFile } from 'fs/promises';
import file from 'gulp-file';
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import OVERRIDES from '../src/overrides.mjs';
// get cloudfront
import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront';
import reader from '../src/playlist-reader.mjs';
const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
@@ -25,11 +25,7 @@ const cloudfront = new CloudFrontClient({ region: 'us-east-1' });
const RESOURCES_PATH = './dist/resources';
const jsSourcesData = [
'server/scripts/data/travelcities.js',
'server/scripts/data/regionalcities.js',
'server/scripts/data/stations.js',
];
// Data is now served as JSON files to avoid redundancy
const webpackOptions = {
mode: 'production',
@@ -55,11 +51,6 @@ const webpackOptions = {
},
};
const compressJsData = () => src(jsSourcesData)
.pipe(concat('data.min.js'))
.pipe(terser())
.pipe(dest(RESOURCES_PATH));
const jsVendorSources = [
'server/scripts/vendor/auto/nosleep.js',
'server/scripts/vendor/auto/swiped-events.js',
@@ -88,6 +79,7 @@ const mjsSources = [
'server/scripts/modules/travelforecast.mjs',
'server/scripts/modules/progress.mjs',
'server/scripts/modules/media.mjs',
'server/scripts/modules/custom-rss-feed.mjs',
'server/scripts/index.mjs',
];
@@ -105,20 +97,27 @@ const copyCss = () => src(cssSources)
const htmlSources = [
'views/*.ejs',
];
const compressHtml = async () => {
const packageJson = await readFile('package.json');
const { version } = JSON.parse(packageJson);
return src(htmlSources)
.pipe(ejs({
production: version,
version,
}))
.pipe(rename({ extname: '.html' }))
.pipe(htmlmin({ collapseWhitespace: true }))
.pipe(dest('./dist'));
const packageJson = await readFile('package.json');
let { version } = JSON.parse(packageJson);
const previewVersion = async () => {
// generate a relatively unique timestamp for cache invalidation of the preview site
const now = new Date();
const msNow = now.getTime() % 1_000_000;
version = msNow.toString();
};
const compressHtml = async () => src(htmlSources)
.pipe(ejs({
production: version,
serverAvailable: false,
version,
OVERRIDES,
query: {},
}))
.pipe(rename({ extname: '.html' }))
.pipe(htmlmin({ collapseWhitespace: true }))
.pipe(dest('./dist'));
const otherFiles = [
'server/robots.txt',
'server/manifest.json',
@@ -127,6 +126,13 @@ const otherFiles = [
const copyOtherFiles = () => src(otherFiles, { base: 'server/', encoding: false })
.pipe(dest('./dist'));
// Copy JSON data files for static hosting
const copyDataFiles = () => src([
'datagenerators/output/travelcities.json',
'datagenerators/output/regionalcities.json',
'datagenerators/output/stations.json',
]).pipe(dest('./dist/data'));
const s3 = s3Upload({
useIAM: true,
}, {
@@ -135,10 +141,13 @@ const s3 = s3Upload({
const uploadSources = [
'dist/**',
'!dist/**/*.map',
'!dist/images/**/*',
'!dist/fonts/**/*',
];
const upload = () => src(uploadSources, { base: './dist', encoding: false })
const uploadCreator = (bucket) => () => src(uploadSources, { base: './dist', encoding: false })
.pipe(s3({
Bucket: process.env.BUCKET,
Bucket: bucket,
StorageClass: 'STANDARD',
maps: {
CacheControl: (keyname) => {
@@ -154,10 +163,14 @@ const imageSources = [
'server/images/**',
'!server/images/gimp/**',
];
const uploadImages = () => src(imageSources, { base: './server', encoding: false })
const upload = uploadCreator(process.env.BUCKET);
const uploadPreview = uploadCreator(process.env.BUCKET_PREVIEW);
const uploadImagesCreator = (bucket) => () => src(imageSources, { base: './server', encoding: false })
.pipe(
s3({
Bucket: process.env.BUCKET,
Bucket: bucket,
StorageClass: 'STANDARD',
maps: {
CacheControl: () => 'max-age=31536000',
@@ -165,8 +178,14 @@ const uploadImages = () => src(imageSources, { base: './server', encoding: false
}),
);
const invalidate = () => cloudfront.send(new CreateInvalidationCommand({
DistributionId: process.env.DISTRIBUTION_ID,
const uploadImages = uploadImagesCreator(process.env.BUCKET);
const uploadImagesPreview = uploadImagesCreator(process.env.BUCKET_PREVIEW);
const copyImageSources = () => src(imageSources, { base: './server', encoding: false })
.pipe(dest('./dist'));
const invalidateCreator = (distributionId) => () => cloudfront.send(new CreateInvalidationCommand({
DistributionId: distributionId,
InvalidationBatch: {
CallerReference: (new Date()).toLocaleString(),
Paths: {
@@ -176,21 +195,26 @@ const invalidate = () => cloudfront.send(new CreateInvalidationCommand({
},
}));
const invalidate = invalidateCreator(process.env.DISTRIBUTION_ID);
const invalidatePreview = invalidateCreator(process.env.DISTRIBUTION_ID_PREVIEW);
const buildPlaylist = async () => {
const availableFiles = await reader();
const playlist = { availableFiles };
return file('playlist.json', JSON.stringify(playlist)).pipe(dest('./dist'));
};
const buildDist = series(clean, parallel(buildJs, compressJsData, compressJsVendor, copyCss, compressHtml, copyOtherFiles, buildPlaylist));
const buildDist = series(clean, parallel(buildJs, compressJsVendor, copyCss, compressHtml, copyOtherFiles, copyDataFiles, copyImageSources, buildPlaylist));
// upload_images could be in parallel with upload, but _images logs a lot and has little changes
// by running upload last the majority of the changes will be at the bottom of the log for easy viewing
const publishFrontend = series(buildDist, uploadImages, upload, invalidate);
const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
export default publishFrontend;
export {
buildDist,
invalidate,
stageFrontend,
};

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
import { src, series, dest } from 'gulp';
import { deleteAsync } from 'del';
import rename from 'gulp-rename';
@@ -6,22 +5,35 @@ import rename from 'gulp-rename';
const clean = () => deleteAsync(['./server/scripts/vendor/auto/**']);
const vendorFiles = [
'./node_modules/luxon/build/es6/luxon.js',
'./node_modules/luxon/build/es6/luxon.js.map',
'./node_modules/luxon/build/es6/luxon.mjs',
'./node_modules/luxon/build/es6/luxon.mjs.map',
'./node_modules/nosleep.js/dist/NoSleep.js',
'./node_modules/suncalc/suncalc.js',
'./node_modules/swiped-events/src/swiped-events.js',
];
// Special handling for metar-taf-parser - only copy main file and English locale
const metarFiles = [
'./node_modules/metar-taf-parser/metar-taf-parser.js',
'./node_modules/metar-taf-parser/locale/en.js',
];
const copy = () => src(vendorFiles)
.pipe(rename((path) => {
path.dirname = path.dirname.toLowerCase();
path.basename = path.basename.toLowerCase();
path.extname = path.extname.toLowerCase();
if (path.basename === 'luxon') path.extname = '.mjs';
}))
.pipe(dest('./server/scripts/vendor/auto'));
const updateVendor = series(clean, copy);
const copyMetar = () => src(metarFiles, { base: './node_modules/metar-taf-parser' })
.pipe(rename((path) => {
path.basename = path.basename.toLowerCase();
path.extname = path.extname.toLowerCase();
if (path.basename === 'metar-taf-parser') path.extname = '.mjs';
}))
.pipe(dest('./server/scripts/vendor/auto'));
const updateVendor = series(clean, copy, copyMetar);
export default updateVendor;

View File

@@ -1,9 +1,10 @@
import updateVendor from './gulp/update-vendor.mjs';
import publishFrontend, { buildDist, invalidate } from './gulp/publish-frontend.mjs';
import publishFrontend, { buildDist, invalidate, stageFrontend } from './gulp/publish-frontend.mjs';
export {
updateVendor,
publishFrontend,
buildDist,
invalidate,
stageFrontend,
};

151
index.mjs
View File

@@ -1,23 +1,31 @@
import 'dotenv/config';
import express from 'express';
import fs from 'fs';
import corsPassThru from './cors/index.mjs';
import radarPassThru from './cors/radar.mjs';
import outlookPassThru from './cors/outlook.mjs';
import { readFile } from 'fs/promises';
import {
weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy,
} from './proxy/handlers.mjs';
import playlist from './src/playlist.mjs';
import OVERRIDES from './src/overrides.mjs';
import cache from './proxy/cache.mjs';
import devTools from './src/com.chrome.devtools.mjs';
const travelCities = JSON.parse(await readFile('./datagenerators/output/travelcities.json'));
const regionalCities = JSON.parse(await readFile('./datagenerators/output/regionalcities.json'));
const stationInfo = JSON.parse(await readFile('./datagenerators/output/stations.json'));
const app = express();
const port = process.env.WS4KP_PORT ?? 8080;
// Set X-Weatherstar header globally for playlist fallback detection
app.use((req, res, next) => {
res.setHeader('X-Weatherstar', 'true');
next();
});
// template engine
app.set('view engine', 'ejs');
// cors pass-thru to api.weather.gov
app.get('/stations/*station', corsPassThru);
app.get('/Conus/*radar', radarPassThru);
app.get('/products/*product', outlookPassThru);
app.get('/playlist.json', playlist);
// version
const { version } = JSON.parse(fs.readFileSync('package.json'));
@@ -44,6 +52,16 @@ const hasQsVars = Object.entries(qsVars).length > 0;
// turn the environment query string into search params
const defaultSearchParams = (new URLSearchParams(qsVars)).toString();
const renderIndex = (req, res, production = false) => {
res.render('index', {
production,
serverAvailable: !process.env?.STATIC, // Disable caching proxy server in static mode
version,
OVERRIDES,
query: req.query,
});
};
const index = (req, res) => {
// test for no query string in request and if environment query string values were provided
if (hasQsVars && Object.keys(req.query).length === 0) {
@@ -53,25 +71,106 @@ const index = (req, res) => {
res.redirect(307, url.toString());
return;
}
// return the standard page
res.render('index', {
production: false,
version,
});
// return the EJS template page in development mode (serve files from server directory directly)
renderIndex(req, res, false);
};
// debugging
const geoip = (req, res) => {
res.set({
'x-geoip-city': 'Orlando',
'x-geoip-country': 'US',
'x-geoip-country-name': 'United States',
'x-geoip-country-region': 'FL',
'x-geoip-country-region-name': 'Florida',
'x-geoip-latitude': '28.52135',
'x-geoip-longitude': '-81.41079',
'x-geoip-postal-code': '32789',
'x-geoip-time-zone': 'America/New_York',
'content-type': 'application/json',
});
res.json({});
};
// Configure static asset caching with proper ETags and cache validation
const staticOptions = {
etag: true, // Enable ETag generation
lastModified: true, // Enable Last-Modified headers
setHeaders: (res, path, stat) => {
// Generate ETag based on file modification time and size for better cache validation
const etag = `"${stat.mtime.getTime().toString(16)}-${stat.size.toString(16)}"`;
res.setHeader('ETag', etag);
if (path.match(/\.(png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$/i)) {
// Images and fonts - cache for 1 year (immutable content)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else if (path.match(/\.(css|js|mjs)$/i)) {
// Scripts and styles - use cache validation instead of no-cache
// This allows browsers to use cached version if ETag matches (304 response)
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
} else {
// Other files - cache for 1 hour with validation
res.setHeader('Cache-Control', 'public, max-age=3600, must-revalidate');
}
},
};
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
// Skip setting up routes for the caching proxy server in static mode
if (!process.env?.STATIC) {
app.use('/api/', weatherProxy);
// Cache management DELETE endpoint to allow "uncaching" specific URLs
app.delete(/^\/cache\/.*/, (req, res) => {
const path = req.url.replace('/cache', '');
const cleared = cache.clearEntry(path);
res.json({ cleared, path });
});
// specific proxies for other services
app.use('/radar/', radarProxy);
app.use('/spc/', outlookProxy);
app.use('/mesonet/', mesonetProxy);
app.use('/forecast/', forecastProxy);
// Playlist route is available in server mode (not in static mode)
app.get('/playlist.json', playlist);
}
// Data endpoints - serve JSON data with long-term caching
const dataEndpoints = {
travelcities: travelCities,
regionalcities: regionalCities,
stations: stationInfo,
};
Object.entries(dataEndpoints).forEach(([name, data]) => {
app.get(`/data/${name}.json`, (req, res) => {
res.set({
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Type': 'application/json',
});
res.json(data);
});
});
if (process.env?.DIST === '1') {
// distribution
app.use('/images', express.static('./server/images'));
app.use('/fonts', express.static('./server/fonts'));
app.use('/scripts', express.static('./server/scripts'));
app.use('/', express.static('./dist'));
// Production ("distribution") mode uses pre-baked files in the dist directory
// 'npm run build' and then 'DIST=1 npm start'
app.use('/scripts', express.static('./server/scripts', staticOptions));
app.use('/geoip', geoip);
// render the EJS template in production mode (serve compressed files from dist directory)
app.get('/', (req, res) => { renderIndex(req, res, true); });
app.use('/', express.static('./dist', staticOptions));
} else {
// debugging
// Development mode serves files from the server directory: 'npm start'
app.get('/index.html', index);
app.use('/geoip', geoip);
app.use('/resources', express.static('./server/scripts/modules'));
app.get('/', index);
app.get('*name', express.static('./server'));
app.get('/.well-known/appspecific/com.chrome.devtools.json', devTools);
app.get('*name', express.static('./server', staticOptions));
}
const server = app.listen(port, () => {
@@ -79,8 +178,12 @@ const server = app.listen(port, () => {
});
// graceful shutdown
process.on('SIGINT', () => {
const gracefulShutdown = () => {
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
};
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);

26
nginx.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 8080;
server_name localhost;
include mime.types;
types {
text/javascript mjs;
}
root /usr/share/nginx/html;
add_header X-Weatherstar true always;
location / {
index redirect.html index.html index.htm;
try_files $uri $uri/ =404;
}
location /music/ {
autoindex on;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

2657
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,20 @@
{
"name": "ws4kp",
"version": "5.20.1",
"version": "6.2.4",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",
"scripts": {
"start": "node index.mjs",
"stop": "pkill -f 'node index.mjs' || echo 'No process found'",
"test": "echo \"Error: no test specified\" && exit 1",
"build:travelcities": "node datagenerators/travelcities.mjs",
"build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css",
"build": "gulp buildDist",
"lint": "eslint ./server/scripts/**/*.mjs",
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs"
"lint": "eslint ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs",
"lint:fix": "eslint --fix ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs",
"lintall": "eslint ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs ./datagenerators/**/*.mjs ./gulp/**/*.mjs ./tests/**/*.mjs",
"lintall:fix": "eslint --fix ./server/scripts/**/*.mjs ./proxy/**/*.mjs ./src/**/*.mjs *.mjs ./datagenerators/**/*.mjs ./gulp/**/*.mjs ./tests/**/*.mjs"
},
"repository": {
"type": "git",
@@ -23,15 +28,18 @@
"homepage": "https://github.com/netbymatt/ws4kp#readme",
"devDependencies": {
"@aws-sdk/client-cloudfront": "^3.609.0",
"@eslint/eslintrc": "^3.3.1",
"ajv": "^8.17.1",
"del": "^8.0.0",
"eslint": "^8.2.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint": "^9.0.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-plugin-import": "^2.10.0",
"gulp": "^5.0.0",
"gulp-awspublish": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-ejs": "^5.1.0",
"gulp-file": "^0.4.0",
"gulp-html-minifier-terser": "^7.1.0",
"gulp-rename": "^2.0.0",
"gulp-s3-uploader": "^1.0.6",
"gulp-sass": "^6.0.0",
@@ -42,11 +50,12 @@
"suncalc": "^1.8.0",
"swiped-events": "^1.1.4",
"terser-webpack-plugin": "^5.3.6",
"webpack": "^5.99.9",
"webpack-stream": "^7.0.0",
"gulp-html-minifier-terser": "^7.1.0"
"metar-taf-parser": "^9.0.0"
},
"dependencies": {
"dotenv": "^16.5.0",
"dotenv": "^17.0.1",
"ejs": "^3.1.5",
"express": "^5.1.0"
}

502
proxy/cache.mjs Normal file
View File

@@ -0,0 +1,502 @@
/**
* HTTP cache implementation for API proxy that respects cache-control headers
* (without external dependencies)
*
* Features:
* - Respects HTTP cache-control headers (s-maxage, max-age)
* - Heuristic caching based on Last-Modified headers when no explicit cache directives exist
* - Conditional requests using ETags and If-Modified-Since headers to validate stale content
* - In-flight request deduplication to prevent multiple simultaneous requests for the same resource
* - Comprehensive logging with cache hit/miss statistics and timing information
* - Timeout handling and error recovery mechanisms
*
* The cache uses a three-state system:
* - 'fresh': Content is within its TTL and served immediately
* - 'stale': Content has expired but can be revalidated with conditional requests (304 Not Modified)
* - 'miss': No cached content exists
*
* @class HttpCache
*/
import https from 'https';
// Default timeout for upstream requests (matches client-side default)
const DEFAULT_REQUEST_TIMEOUT = 15000;
class HttpCache {
constructor() {
this.cache = new Map();
this.inFlight = new Map();
this.cleanupInterval = null;
this.startCleanup();
}
// Parse cache-control header to extract s-maxage or max-age
static parseCacheControl(cacheControlHeader) {
if (!cacheControlHeader) return 0;
// Look for s-maxage first (preferred for proxy caches), then max-age
const sMaxAgeMatch = cacheControlHeader.match(/s-maxage=(\d+)/i);
if (sMaxAgeMatch) {
return parseInt(sMaxAgeMatch[1], 10);
}
const maxAgeMatch = cacheControlHeader.match(/max-age=(\d+)/i);
if (maxAgeMatch) {
return parseInt(maxAgeMatch[1], 10);
}
return 0; // No cache if no cache directives found
}
// Helper method to set filtered headers and our cache policy
static setFilteredHeaders(res, headers) {
// Strip cache-related headers and pass through others
Object.entries(headers || {}).forEach(([key, value]) => {
const lowerKey = key.toLowerCase();
// Skip cache-related headers that should be controlled by our proxy
if (!['cache-control', 'expires', 'etag', 'last-modified'].includes(lowerKey)) {
res.header(lowerKey, value);
}
});
// Set our own cache policy - short cache to ensure browser checks back with our server
res.header('cache-control', 'public, max-age=30');
}
// Generate cache key from request
static generateKey(req) {
const path = req.path || req.url || '/';
const url = req.url || req.path || '/';
// Since this cache is intended only by the frontend, we can use a simple URL-based key
return `${path}${url.includes('?') ? url.substring(url.indexOf('?')) : ''}`;
}
// High-level method to handle caching for HTTP proxies
async handleRequest(req, res, upstreamUrl, options = {}) {
// Check cache status
const cacheResult = this.getCachedRequest(req);
if (cacheResult.status === 'fresh') {
const cached = cacheResult.data;
res.status(cached.statusCode);
HttpCache.setFilteredHeaders(res, cached.headers);
res.send(cached.data);
return true; // Indicates cache hit
}
// For 'miss' or 'stale', proceed to upstream request
// Generate cache key for in-flight tracking
const cacheKey = HttpCache.generateKey(req);
// Build the full URL
const queryParams = Object.keys(req.query || {}).reduce((acc, key) => {
if (options.skipParams && options.skipParams.includes(key)) return acc;
acc[key] = req.query[key];
return acc;
}, {});
const queryString = new URLSearchParams(queryParams).toString();
const fullUrl = `${upstreamUrl}${req.path}${queryString ? `?${queryString}` : ''}`;
// Check if there's already a request in flight for this resource
if (this.inFlight.has(cacheKey)) {
console.log(`🛫 Wait | ${fullUrl} (request already in flight)`);
// Track when we start waiting for latency measurement
const waitStartTime = Date.now();
// Wait for the in-flight request to complete
try {
await this.inFlight.get(cacheKey);
// After waiting, try cache again (should be populated now if the request was successful)
const key = HttpCache.generateKey(req);
const cached = this.cache.get(key);
if (cached && Date.now() <= cached.expiry) {
const waitLatency = Date.now() - waitStartTime;
// Log cache hit with wait latency
const age = Math.round((Date.now() - cached.timestamp) / 1000);
const remainingTTL = Math.round((cached.expiry - Date.now()) / 1000);
const url = cached.url || `${upstreamUrl}${req.path}`;
console.log(`🛬 Continue | ${url} (age: ${age}s, remaining: ${remainingTTL}s, waited: ${waitLatency}ms)`);
res.status(cached.statusCode);
HttpCache.setFilteredHeaders(res, cached.headers);
res.send(cached.data);
return true; // Served from cache after waiting
}
// Fallthrough to make request if cache miss (shouldn't happen but safety net)
console.warn(`⚠️ Redo | Cache miss after waiting for in-flight request: ${fullUrl}`);
} catch (_error) {
// If the in-flight request failed, we'll make our own request
console.warn(`⚠️ Redo | In-flight request failed, making new request: ${fullUrl}`);
}
}
// Create promise for this request
const requestPromise = this.makeUpstreamRequest(req, res, fullUrl, options, cacheResult);
// Store a wrapped promise that doesn't reject for waiters - they just need to know when it's done
const inflightPromise = requestPromise.catch(() => null);
this.inFlight.set(cacheKey, inflightPromise);
try {
// Send the request to the upstream service
const result = await requestPromise;
return result;
} catch (error) {
// All errors are handled directly by makeUpstreamRequest so this is a safety net
console.error(`💥 Error | Unhandled error in handleRequest: ${error.message}`);
return false;
} finally {
// Always clean up the in-flight tracking
this.inFlight.delete(cacheKey);
}
}
// Make the upstream request, handling caching and conditional requests
async makeUpstreamRequest(req, res, fullUrl, options = {}, cacheResult = null) {
return new Promise((resolve) => {
const headers = {
'user-agent': options.userAgent || '(WeatherStar 4000+, ws4000@netbymatt.com)',
accept: req.headers?.accept || '*/*',
...options.headers,
};
// Use the cache result passed from handleRequest (no additional cache call)
let staleCache = null;
if (cacheResult && cacheResult.status === 'stale' && cacheResult.data?.originalHeaders) {
staleCache = cacheResult.data;
// Add conditional headers based on cached etag or last-modified header
if (staleCache.originalHeaders.etag) {
headers['if-none-match'] = staleCache.originalHeaders.etag;
// console.log(`🏷️ Added | If-None-Match: ${staleCache.originalHeaders.etag} for ${fullUrl}`);
} else if (staleCache.originalHeaders['last-modified']) {
headers['if-modified-since'] = staleCache.originalHeaders['last-modified'];
// console.log(`📅 Added | If-Modified-Since: ${staleCache.originalHeaders['last-modified']} for ${fullUrl}`);
}
}
let responseHandled = false; // Track if we've already sent a response
const upstreamReq = https.get(fullUrl, { headers }, (getRes) => {
const { statusCode } = getRes;
// Handle 304 Not Modified responses - refresh stale cache and serve
if (statusCode === 304) {
if (responseHandled) return; // Prevent double response
responseHandled = true;
if (staleCache) {
const newCacheControl = getRes.headers['cache-control'];
const newMaxAge = HttpCache.parseCacheControl(newCacheControl);
if (newMaxAge > 0) {
staleCache.expiry = Date.now() + (newMaxAge * 1000);
staleCache.timestamp = Date.now(); // Reset age counter for 304 refresh
console.log(`〰️ NoChange | ${fullUrl} (got 304 Not Modified; refreshing cache expiry by ${newMaxAge}s)`);
} else {
console.log(`📉 NoCache | ${fullUrl} (no valid cache directives in 304, not updating expiry)`);
}
res.status(staleCache.statusCode);
HttpCache.setFilteredHeaders(res, staleCache.headers);
res.send(staleCache.data);
resolve(true); // Cache hit after 304 validation
return;
}
// No stale entry for 304 response (this shouldn't happen!)
console.error(`💥 Error | 304 response but no stale cache entry for ${fullUrl}`);
res.status(500).json({ error: 'Cache inconsistency error' });
resolve(false); // Error handled, response sent
return;
}
// Helper function to handle response after data collection
const handleResponse = (data) => {
if (responseHandled) return; // Prevent double response
responseHandled = true;
// Log HTTP error status codes
if (statusCode >= 400) {
console.error(`🚫 ${statusCode} | ${fullUrl}`);
}
// Filter out cache headers before storing - we don't need them in our cache
const filteredHeaders = {};
Object.entries(getRes.headers || {}).forEach(([key, value]) => {
const lowerKey = key.toLowerCase();
if (!['cache-control', 'expires', 'etag', 'last-modified'].includes(lowerKey)) {
filteredHeaders[key] = value;
}
});
const response = {
statusCode,
headers: filteredHeaders,
data,
};
// Check if this is a server error (5xx) or client error that shouldn't be cached
if (statusCode >= 500 && statusCode <= 599) {
// For 5xx errors, send response (don't cache, but don't reject since response is sent)
res.status(statusCode);
HttpCache.setFilteredHeaders(res, getRes.headers);
res.send(response.data);
resolve(false); // Error response sent successfully
return;
}
// For 4xx errors, don't cache but send the response
if (statusCode >= 400 && statusCode <= 499) {
res.status(statusCode);
HttpCache.setFilteredHeaders(res, getRes.headers);
res.send(response.data);
resolve(true); // Successful HTTP transaction (client error, but valid response; don't retry)
return;
}
// Store in cache (pass original headers for cache logic, but store filtered headers)
this.storeCachedResponse(req, response, fullUrl, getRes.headers);
// Send response to client
res.status(statusCode);
// Set filtered headers and our cache policy
HttpCache.setFilteredHeaders(res, getRes.headers);
res.send(response.data);
resolve(true); // Indicates successful response from upstream
};
if (options.encoding === 'binary') {
// For binary data, collect as Buffer chunks
const chunks = [];
getRes.on('data', (chunk) => chunks.push(chunk));
getRes.on('end', () => handleResponse(Buffer.concat(chunks)));
getRes.on('error', (err) => {
if (responseHandled) return;
responseHandled = true;
console.error(`💥 Error | with stream ${fullUrl}: ${err.message}`);
res.status(500).json({ error: `Stream error: ${err.message}` });
resolve(false); // Error handled, response sent
});
} else {
// For text data, use string encoding
let data = '';
getRes.setEncoding(options.encoding || 'utf8');
getRes.on('data', (chunk) => {
data += chunk;
});
getRes.on('end', () => handleResponse(data));
getRes.on('error', (err) => {
if (responseHandled) return;
responseHandled = true;
console.error(`💥 Error | with stream ${fullUrl}: ${err.message}`);
res.status(500).json({ error: `Stream error: ${err.message}` });
resolve(false); // Error handled, response sent
});
}
});
upstreamReq.on('error', (err) => {
if (responseHandled) return; // Prevent double response
responseHandled = true;
console.error(`💥 Error | ${fullUrl}: ${err.message}`);
res.status(500).json({ error: `Failed to fetch data from ${options.serviceName || 'upstream API'}` });
resolve(false); // Error handled, response sent
});
upstreamReq.setTimeout(options.timeout || DEFAULT_REQUEST_TIMEOUT, () => {
if (responseHandled) return; // Prevent double response
responseHandled = true;
console.error(`⏲️ Timeout | ${fullUrl} (after ${options.timeout || DEFAULT_REQUEST_TIMEOUT}ms)`);
// Send timeout response to client
res.status(504).json({ error: 'Gateway timeout' });
// Don't destroy the request immediately - let the response be sent first
// Then destroy to clean up the upstream connection
setImmediate(() => {
if (!upstreamReq.destroyed) {
upstreamReq.destroy();
}
});
resolve(false); // Timeout handled, response sent
});
});
}
getCachedRequest(req) {
const key = HttpCache.generateKey(req);
const cached = this.cache.get(key);
if (!cached) {
return { status: 'miss', data: null };
}
const isExpired = Date.now() > cached.expiry;
// If fresh, return immediately
if (!isExpired) {
const age = Math.round((Date.now() - cached.timestamp) / 1000);
const remainingTTL = Math.round((cached.expiry - Date.now()) / 1000);
console.log(`🎯 Hit | ${cached.url} (age: ${age}s, remaining: ${remainingTTL}s)`);
return { status: 'fresh', data: cached };
}
// If stale, return for potential conditional request
// const staleAge = Math.round((Date.now() - cached.expiry) / 1000);
// console.log(`🕐 Stale | ${cached.url} (expired ${staleAge}s ago, will check upstream)`);
return { status: 'stale', data: cached };
}
storeCachedResponse(req, response, url, originalHeaders) {
const key = HttpCache.generateKey(req);
const cacheControl = (originalHeaders || {})['cache-control'];
let maxAge = HttpCache.parseCacheControl(cacheControl);
let cacheType = '';
// If no explicit cache directives, try heuristic caching for Last-Modified
if (maxAge <= 0) {
const lastModified = (originalHeaders || {})['last-modified'];
if (lastModified) {
maxAge = HttpCache.calculateHeuristicMaxAge(lastModified);
cacheType = 'heuristic';
}
} else {
cacheType = 'explicit';
}
// Don't cache if still no valid max-age
if (maxAge <= 0) {
console.log(`📤 Sent | ${url} (no cache directives; not cached)`);
return;
}
const cached = {
statusCode: response.statusCode,
headers: { ...(response.headers || {}) },
data: response.data,
expiry: Date.now() + (maxAge * 1000),
timestamp: Date.now(),
url, // Store the URL for logging
originalHeaders: { // Store original headers for conditional requests
etag: (originalHeaders || {}).etag,
'last-modified': (originalHeaders || {})['last-modified'],
},
};
this.cache.set(key, cached);
console.log(`🌐 Add | ${url} (${cacheType} ${maxAge}s TTL, expires: ${new Date(cached.expiry).toISOString()})`);
}
// Calculate heuristic max-age based on Last-Modified header
// RFC 7234: A cache can use heuristic freshness calculation
// Common heuristic: 10% of the age of the resource, with limits
static calculateHeuristicMaxAge(lastModifiedHeader) {
try {
const lastModified = new Date(lastModifiedHeader);
const now = new Date();
const age = (now.getTime() - lastModified.getTime()) / 1000; // age in seconds
if (age <= 0) return 0;
// Use 10% of age, but limit between 1 hour and 4 hours
const heuristicAge = Math.floor(age * 0.1);
const minAge = 60 * 60; // 1 hour
const maxAge = 4 * 60 * 60; // 4 hours
return Math.max(minAge, Math.min(maxAge, heuristicAge));
} catch (_error) {
return 0; // Invalid date format
}
}
// Periodic cleanup of expired entries
startCleanup() {
if (this.cleanupInterval) return;
this.cleanupInterval = setInterval(() => {
const now = Date.now();
let removedCount = 0;
Array.from(this.cache.entries()).forEach(([key, cached]) => {
// Allow stale entries to persist for up to 3 hours before cleanup
// This gives us time to make conditional requests and potentially refresh them
const staleTimeLimit = 3 * 60 * 60 * 1000;
if (now > cached.expiry + staleTimeLimit) {
this.cache.delete(key);
removedCount += 1;
}
});
if (removedCount > 0) {
console.log(`🧹 Clean | Removed ${removedCount} stale entries (${this.cache.size} remaining)`);
}
}, 5 * 60 * 1000); // Cleanup every 5 minutes
}
// Cache statistics
getStats() {
const now = Date.now();
let expired = 0;
let valid = 0;
Array.from(this.cache.values()).forEach((cached) => {
if (now > cached.expiry) {
expired += 1;
} else {
valid += 1;
}
});
return {
total: this.cache.size,
valid,
expired,
inFlight: this.inFlight.size,
};
}
// Clear all cache entries
clear() {
this.cache.clear();
console.log('🗑️ Clear | Cache cleared');
}
// Clear a specific cache entry by path
clearEntry(path) {
const key = path;
const deleted = this.cache.delete(key);
if (deleted) {
console.log(`🗑️ Clear | ${path} removed from cache`);
return true;
}
return false;
}
// Stop cleanup interval
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.clear();
this.inFlight.clear();
}
}
// Create singleton instance of our cache
const cache = new HttpCache();
export default cache;

52
proxy/handlers.mjs Normal file
View File

@@ -0,0 +1,52 @@
// Consolidated proxy handlers for all external API requests with caching
import cache from './cache.mjs';
import OVERRIDES from '../src/overrides.mjs';
// Weather.gov API proxy (catch-all for any Weather.gov API endpoint)
export const weatherProxy = async (req, res) => {
await cache.handleRequest(req, res, 'https://api.weather.gov', {
serviceName: 'Weather.gov',
skipParams: ['u'],
});
};
// Radar proxy for weather radar images
export const radarProxy = async (req, res) => {
await cache.handleRequest(req, res, 'https://radar.weather.gov', {
serviceName: 'Radar',
skipParams: ['u'],
encoding: 'binary', // Radar images are binary data
});
};
// SPC (Storm Prediction Center) outlook proxy
export const outlookProxy = async (req, res) => {
await cache.handleRequest(req, res, 'https://www.spc.noaa.gov', {
serviceName: 'SPC Outlook',
skipParams: ['u'],
});
};
// Iowa State Mesonet proxy with configurable host
export const mesonetProxy = async (req, res) => {
// Determine if this is a binary file (images)
const isBinary = req.path.match(/\.(png|jpg|jpeg|gif|webp|ico)$/i);
// Use override radar host if provided, otherwise default to mesonet
const radarHost = OVERRIDES.RADAR_HOST || 'mesonet.agron.iastate.edu';
await cache.handleRequest(req, res, `https://${radarHost}`, {
serviceName: `Iowa State Mesonet (${radarHost})`,
skipParams: [], // No parameters to skip for Mesonet
encoding: isBinary ? 'binary' : 'utf8', // Use binary encoding for images
});
};
// Legacy forecast.weather.gov API proxy
export const forecastProxy = async (req, res) => {
await cache.handleRequest(req, res, 'https://forecast.weather.gov', {
serviceName: 'Forecast.weather.gov',
skipParams: ['u'],
});
};

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Some files were not shown because too many files have changed in this diff Show More