Compare commits
300 Commits
v5.21.7
...
c07ebe8bdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c07ebe8bdd | ||
|
|
a41b0da196 | ||
|
|
30887202c8 | ||
|
|
38d1455a4b | ||
|
|
30ec847ed5 | ||
|
|
11c54391b2 | ||
|
|
0b47cf79c1 | ||
|
|
ba36904477 | ||
|
|
dae5b20bc6 | ||
|
|
ccc936d81a | ||
|
|
5dc214c6a5 | ||
|
|
2a4dc03cf7 | ||
|
|
8c13128005 | ||
|
|
942fa8b817 | ||
|
|
15b68eba2f | ||
|
|
933a289d03 | ||
|
|
e6121327ce | ||
|
|
678f04fe42 | ||
|
|
77592a08a3 | ||
|
|
dadfcb8a5c | ||
|
|
245e9daf9c | ||
|
|
177012317b | ||
|
|
7bd21bcf1d | ||
|
|
ec65025ae2 | ||
|
|
194e108037 | ||
|
|
d5b7c6630a | ||
|
|
39bafae394 | ||
|
|
ec8fffbb64 | ||
|
|
0a794eae36 | ||
|
|
8c83736aba | ||
|
|
872162080d | ||
|
|
69d2b0f40b | ||
|
|
37193112a7 | ||
|
|
0d9c445919 | ||
|
|
6c9fb4cf68 | ||
|
|
59b10ae222 | ||
|
|
d18b13821a | ||
|
|
320d3139c3 | ||
|
|
34dedb44c1 | ||
|
|
18633708f9 | ||
|
|
9b12255e0a | ||
|
|
f3360772c8 | ||
|
|
767bb8f11d | ||
|
|
7586dd7489 | ||
|
|
f37cbd66f7 | ||
|
|
d00262ebbc | ||
|
|
b4646b128a | ||
|
|
9f78761fe8 | ||
|
|
31c060c6d9 | ||
|
|
770f671d45 | ||
|
|
da3fe3366c | ||
|
|
6f97e3d2b9 | ||
|
|
8255efd3f7 | ||
|
|
1c79b08228 | ||
|
|
66a161762e | ||
|
|
707b08ee1a | ||
|
|
7900e59aab | ||
|
|
9b422dd697 | ||
|
|
e4ce0b6cc6 | ||
|
|
b0e5018179 | ||
|
|
6422589b5c | ||
|
|
407da90f8a | ||
|
|
3a0e6aa345 | ||
|
|
650dda7b61 | ||
|
|
8f1e8ffb74 | ||
|
|
93af84cbd8 | ||
|
|
117f66e9d0 | ||
|
|
bca9376edc | ||
|
|
8b076db25d | ||
|
|
807932fe3c | ||
|
|
7bb024eff5 | ||
|
|
f4a1a3a1d8 | ||
|
|
9a5efe9d48 | ||
|
|
58e0611a46 | ||
|
|
9ed496c892 | ||
|
|
31315d1ace | ||
|
|
77838e1a81 | ||
|
|
64d6484bd8 | ||
|
|
20cab8c25e | ||
|
|
b4de17ccd0 | ||
|
|
0fd90feb7a | ||
|
|
8c3b596b69 | ||
|
|
e57b9bcb20 | ||
|
|
e27750e915 | ||
|
|
f5431a04c7 | ||
|
|
5117a9d475 | ||
|
|
28baa022a9 | ||
|
|
e8b8890260 | ||
|
|
b797a10b9e | ||
|
|
2a64cda383 | ||
|
|
e6e357c51b | ||
|
|
24deb4dce4 | ||
|
|
14b1891efd | ||
|
|
f17f69f60e | ||
|
|
fa16095355 | ||
|
|
cc3dbeb043 | ||
|
|
8ee1e954eb | ||
|
|
bfc4bddfef | ||
|
|
567325e3c5 | ||
|
|
4903b95fec | ||
|
|
b43fb32820 | ||
|
|
0d0c4ec452 | ||
|
|
49d18c2fbe | ||
|
|
1732a3381f | ||
|
|
cc05aafb95 | ||
|
|
093b6ac239 | ||
|
|
12d068d740 | ||
|
|
517c560ef6 | ||
|
|
3eb571bed4 | ||
|
|
52ca161bdb | ||
|
|
ee5690dcad | ||
|
|
c05b827593 | ||
|
|
bef42a3da2 | ||
|
|
13ff0317e6 | ||
|
|
5cc85840a9 | ||
|
|
190e50e2f3 | ||
|
|
aa7ac64827 | ||
|
|
2ab737d5a5 | ||
|
|
ecf0999675 | ||
|
|
6a49b7b6ce | ||
|
|
5ffff03db9 | ||
|
|
c8a25e5d9a | ||
|
|
ea58b5a9c8 | ||
|
|
4bf725413b | ||
|
|
75eb81887f | ||
|
|
9b37bc5c52 | ||
|
|
8a22e23d5a | ||
|
|
0d508d7f50 | ||
|
|
d85a5ed3b1 | ||
|
|
831e1680e9 | ||
|
|
73cbc0aa81 | ||
|
|
eb412a0cae | ||
|
|
9150d42802 | ||
|
|
54257e4667 | ||
|
|
7d50ce28bd | ||
|
|
c3d863f89f | ||
|
|
996baa78aa | ||
|
|
e81c957416 | ||
|
|
d25a632f7d | ||
|
|
4b34ffabcb | ||
|
|
2db7f30de7 | ||
|
|
5c7a6ab1a4 | ||
|
|
ee4f84689a | ||
|
|
804d9e9e33 | ||
|
|
3e8135a36a | ||
|
|
9c5ed0dcca | ||
|
|
a3c581aa93 | ||
|
|
771ab37eaf | ||
|
|
4b63328b74 | ||
|
|
ae1d004f60 | ||
|
|
2a975d4d6d | ||
|
|
7dd4c1dd24 | ||
|
|
10baefc901 | ||
|
|
46edf1f7e2 | ||
|
|
67dfd7ec08 | ||
|
|
2761f76117 | ||
|
|
13621b6f46 | ||
|
|
b49433f5ff | ||
|
|
1120247c99 | ||
|
|
c5c01e5450 | ||
|
|
0a65221905 | ||
|
|
cc9e613ba7 | ||
|
|
9f9667c895 | ||
|
|
fda44e95fc | ||
|
|
945c12e6c6 | ||
|
|
0fde88cd8f | ||
|
|
c6af9a2913 | ||
|
|
11eba84cdb | ||
|
|
b9ead38015 | ||
|
|
3d0178faa1 | ||
|
|
b1c4e6d850 | ||
|
|
8a2907e02c | ||
|
|
b870ce1c01 | ||
|
|
90c1ab92b4 | ||
|
|
15107ffe1c | ||
|
|
efd4e0c66d | ||
|
|
652d7c5fb0 | ||
|
|
5a80f43f30 | ||
|
|
6d090cb1c7 | ||
|
|
17585e97c4 | ||
|
|
517cafe40a | ||
|
|
7f7cb96231 | ||
|
|
bfd0c2b02d | ||
|
|
c8b520b752 | ||
|
|
ebface1749 | ||
|
|
137c2f6d08 | ||
|
|
bec80a1ebe | ||
|
|
e34137e430 | ||
|
|
c0e2eaf33a | ||
|
|
975bccdac5 | ||
|
|
8ead95c041 | ||
|
|
8f34aa5139 | ||
|
|
e472b99b44 | ||
|
|
09a21e6f5a | ||
|
|
dd680c61b0 | ||
|
|
79de691eef | ||
|
|
ec83c17ae2 | ||
|
|
5630067530 | ||
|
|
506ac1f4df | ||
|
|
0e0ea3c378 | ||
|
|
bf65b8e426 | ||
|
|
ca272de8bf | ||
|
|
65944dc3b5 | ||
|
|
be41d66de9 | ||
|
|
7a07c67e84 | ||
|
|
b5fa3e49d6 | ||
|
|
ef0b60a0b8 | ||
|
|
dc13140cc4 | ||
|
|
5414b1f5bc | ||
|
|
1fdc3635e6 | ||
|
|
e2cc86cddd | ||
|
|
92181c716d | ||
|
|
208ca3d87f | ||
|
|
7167bb18fb | ||
|
|
daa81ebf94 | ||
|
|
543a8df9a2 | ||
|
|
b1347bcc3c | ||
|
|
c7e170b1a3 | ||
|
|
3d75384848 | ||
|
|
bf4819b241 | ||
|
|
7dd6050416 | ||
|
|
bb0ad8ff32 | ||
|
|
9eb192146a | ||
|
|
1b6e6ad142 | ||
|
|
f0d4a9e6f0 | ||
|
|
a2cbe7f5c8 | ||
|
|
57395b8dc7 | ||
|
|
51bb9696b0 | ||
|
|
a11e783cde | ||
|
|
e2e22517b6 | ||
|
|
d8e0399e92 | ||
|
|
f456897520 | ||
|
|
9303035bb9 | ||
|
|
3c40219003 | ||
|
|
6ff7122844 | ||
|
|
9f94ef83ba | ||
|
|
3236b2ecc3 | ||
|
|
2827913d42 | ||
|
|
1ac514293b | ||
|
|
392b339727 | ||
|
|
852eff8de6 | ||
|
|
c74a15c40c | ||
|
|
5419425834 | ||
|
|
f3a386079b | ||
|
|
aa43713943 | ||
|
|
1dece10679 | ||
|
|
c4f16d786a | ||
|
|
36b8adc019 | ||
|
|
bfe0b4757d | ||
|
|
2b61e55783 | ||
|
|
36c4f451b3 | ||
|
|
268d4ae7fa | ||
|
|
1b49e02cd8 | ||
|
|
9a55a6ec39 | ||
|
|
faaf6f770f | ||
|
|
79e4ed6e8b | ||
|
|
f956df1272 | ||
|
|
089ef56b10 | ||
|
|
c4e8721a2b | ||
|
|
a2efc2f767 | ||
|
|
c0e1c55453 | ||
|
|
860ca52e2d | ||
|
|
b891a1e3c0 | ||
|
|
70fb3b5dbe | ||
|
|
5bcc744867 | ||
|
|
da75226a63 | ||
|
|
cab4219740 | ||
|
|
9252275436 | ||
|
|
9d1c21d8ef | ||
|
|
6473f167a8 | ||
|
|
d280a5b3a9 | ||
|
|
b195ce042b | ||
|
|
39e8879697 | ||
|
|
5e3b917023 | ||
|
|
a813ee19a7 | ||
|
|
e01edc6972 | ||
|
|
ab0249e6eb | ||
|
|
c4c85b3b7b | ||
|
|
e954033979 | ||
|
|
ba39af9126 | ||
|
|
a814fde5b5 | ||
|
|
3f5f78eddf | ||
|
|
974a061b44 | ||
|
|
dc64e4bd7f | ||
|
|
776148fa6b | ||
|
|
7c50f5f1d7 | ||
|
|
4bf3f4d1e0 | ||
|
|
46da573715 | ||
|
|
69c050eb8f | ||
|
|
a3e142dade | ||
|
|
28917489bb | ||
|
|
2365a4c0f7 | ||
|
|
8afef77ea5 | ||
|
|
8f70ee87c5 | ||
|
|
4e7429bfba | ||
|
|
c5ffe1542a | ||
|
|
5364855c58 | ||
|
|
18efd810bd | ||
|
|
68a6bae3a7 | ||
|
|
5f0f0d9000 |
@@ -1,2 +0,0 @@
|
||||
*.min.*
|
||||
server/scripts/vendor/*
|
||||
92
.eslintrc
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
{
|
||||
"mjs": "always",
|
||||
"json": "always"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"*.min.js"
|
||||
]
|
||||
}
|
||||
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -11,4 +11,5 @@ 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
|
||||
* How you're running Weatherstar (Node, Dockerfile, Dockerfile.server, etc.)
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/naming _issue.md
vendored
Normal 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.
|
||||
11
.github/ISSUE_TEMPLATE/screen_enhance.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: Screen Enhancement
|
||||
about: Items and tasks related to the screen enhancement project
|
||||
title: '[Project]: '
|
||||
labels: screen-enhance
|
||||
projects: ['netbymatt/5']
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Describe the task, how it affects the overall project and what is considered complete.
|
||||
25
.github/workflows/build-docker.yaml
vendored
@@ -1,5 +1,14 @@
|
||||
name: build-docker
|
||||
on: push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
- '!screen-enhance'
|
||||
- '!screen-enhance/**'
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- 'v*.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -13,7 +22,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 +36,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
|
||||
|
||||
12
.vscode/launch.json
vendored
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
5
.vscode/settings.json
vendored
@@ -2,7 +2,7 @@
|
||||
"liveSassCompile.settings.formats": [
|
||||
{
|
||||
"format": "compressed",
|
||||
"extensionName": ".css",
|
||||
"extensionName": ".min.css",
|
||||
"savePath": "/server/styles",
|
||||
}
|
||||
],
|
||||
@@ -17,7 +17,4 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript"
|
||||
],
|
||||
}
|
||||
19
Dockerfile
@@ -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
@@ -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"]
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2024 Matt Walsh
|
||||
Copyright (c) 2020-2026 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
|
||||
|
||||
496
README.md
@@ -1,10 +1,373 @@
|
||||

|
||||
|
||||
# WeatherStar 4000+
|
||||
|
||||
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
|
||||
|
||||
## Quick 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¤t-weather-checkbox=true&latest-observations-checkbox=true&hourly-checkbox=false&hourly-graph-checkbox=true&travel-checkbox=false®ional-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. This will start Weatherstar in a fullscreen-like view without the play/volume/etc toolbar and scaled to fill the entire space. This does not activate the browser's fullscreen or kiosk mode. Those can only be activated by user interaction or by launching the browser with specific parameters such as `--start-fullscreen` or `--kiosk`.
|
||||
|
||||
When using kiosk mode (via the checkbox), there will be no way to exit the fullscreen-like view of weatherstar. Reloading the page should remove the kiosk checkbox and return you to the normal view. 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`
|
||||
|
||||
### Custom text scroll
|
||||
If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, turn on the setting for `Enable RSS Feed/Text` and then enter text in the resulting text box. Then press set.
|
||||
|
||||
Tip: You can have Weatherstar select randomly between several text strings on each pass through the current conditions. Use a pipe character to separate string. `Welcome to Weatherstar|Thanks for watching`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Phone App
|
||||
|
||||
An Android app is in a closed beta test. It's nothing too special, just a wrapper for displaying the website in a browser.
|
||||
|
||||
You can get this functionality without an app on both Andriod and iOS by using the install or add to home screen feature of your browser.
|
||||
|
||||
iOS native app? No. I own zero Apple devices and thus have no way to develop, test, compile or verify myself to the app store. That application will have to come from the community.
|
||||
|
||||
## 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 +378,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.mjs
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -729,13 +729,23 @@
|
||||
"wfo": "LMK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"city": "Lubbock",
|
||||
"lat": 33.5836,
|
||||
"lon": -101.8549,
|
||||
"point": {
|
||||
"x": 49,
|
||||
"y": 34,
|
||||
"wfo": "LUB"
|
||||
}
|
||||
},
|
||||
{
|
||||
"city": "Manchester",
|
||||
"lat": 42.9956,
|
||||
"lon": -71.4548,
|
||||
"point": {
|
||||
"x": 42,
|
||||
"y": 21,
|
||||
"x": 38,
|
||||
"y": 20,
|
||||
"wfo": "GYX"
|
||||
}
|
||||
},
|
||||
@@ -884,8 +894,8 @@
|
||||
"lat": 43.6615,
|
||||
"lon": -70.2553,
|
||||
"point": {
|
||||
"x": 76,
|
||||
"y": 59,
|
||||
"x": 72,
|
||||
"y": 58,
|
||||
"wfo": "GYX"
|
||||
}
|
||||
},
|
||||
|
||||
16102
datagenerators/output/stations-raw.json
Normal file
@@ -84,8 +84,8 @@
|
||||
"Latitude": 29.7633,
|
||||
"Longitude": -95.3633,
|
||||
"point": {
|
||||
"x": 65,
|
||||
"y": 97,
|
||||
"x": 63,
|
||||
"y": 95,
|
||||
"wfo": "HGX"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
"lon": -82.5329
|
||||
},
|
||||
{
|
||||
"city": "Washington DC",
|
||||
"city": "Washington",
|
||||
"lat": 38.8951,
|
||||
"lon": -77.0364
|
||||
},
|
||||
@@ -364,6 +364,11 @@
|
||||
"lat": 38.2542,
|
||||
"lon": -85.7594
|
||||
},
|
||||
{
|
||||
"city": "Lubbock",
|
||||
"lat": 33.5836,
|
||||
"lon": -101.8549
|
||||
},
|
||||
{
|
||||
"city": "Manchester",
|
||||
"lat": 42.9956,
|
||||
|
||||
19
datagenerators/stations-overrides.mjs
Normal 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;
|
||||
1294
datagenerators/stations-postprocessor.mjs
Normal 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,88 @@ 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';
|
||||
import { stationFilter } from '../server/scripts/modules/utils/string.mjs';
|
||||
|
||||
// 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'];
|
||||
// check for cached flag
|
||||
const USE_CACHE = process.argv.includes('--use-cache');
|
||||
|
||||
// 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 against starting letter
|
||||
const stationsFiltered = stations.filter(stationFilter);
|
||||
// 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
@@ -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',
|
||||
],
|
||||
}),
|
||||
];
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import 'dotenv/config';
|
||||
import {
|
||||
src, dest, series, parallel,
|
||||
@@ -15,22 +14,24 @@ 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 log from 'fancy-log';
|
||||
import dartSass from 'sass';
|
||||
import gulpSass from 'gulp-sass';
|
||||
import sourceMaps from 'gulp-sourcemaps';
|
||||
import OVERRIDES from '../src/overrides.mjs';
|
||||
|
||||
// get cloudfront
|
||||
import reader from '../src/playlist-reader.mjs';
|
||||
|
||||
const sass = gulpSass(dartSass);
|
||||
|
||||
const clean = () => deleteAsync(['./dist/**/*', '!./dist/readme.txt']);
|
||||
|
||||
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',
|
||||
@@ -40,6 +41,7 @@ const webpackOptions = {
|
||||
resolve: {
|
||||
roots: ['./'],
|
||||
},
|
||||
devtool: 'source-map',
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
@@ -56,11 +58,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',
|
||||
@@ -89,6 +86,7 @@ const mjsSources = [
|
||||
'server/scripts/modules/travelforecast.mjs',
|
||||
'server/scripts/modules/progress.mjs',
|
||||
'server/scripts/modules/media.mjs',
|
||||
'server/scripts/modules/custom-scroll-text.mjs',
|
||||
'server/scripts/index.mjs',
|
||||
];
|
||||
|
||||
@@ -97,30 +95,39 @@ const buildJs = () => src(mjsSources)
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
|
||||
const cssSources = [
|
||||
'server/styles/main.css',
|
||||
'server/styles/scss/**/*.scss',
|
||||
];
|
||||
const copyCss = () => src(cssSources)
|
||||
.pipe(concat('ws.min.css'))
|
||||
const buildCss = () => src(cssSources)
|
||||
.pipe(sourceMaps.init())
|
||||
.pipe(sass({ style: 'compressed' }).on('error', sass.logError))
|
||||
.pipe(rename({ suffix: '.min' }))
|
||||
.pipe(sourceMaps.write('./'))
|
||||
.pipe(dest(RESOURCES_PATH));
|
||||
|
||||
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,
|
||||
OVERRIDES,
|
||||
}))
|
||||
.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',
|
||||
@@ -129,6 +136,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,
|
||||
}, {
|
||||
@@ -136,11 +150,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) => {
|
||||
@@ -156,10 +172,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',
|
||||
@@ -167,8 +187,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: {
|
||||
@@ -178,21 +204,30 @@ 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 logVersion = async () => {
|
||||
log(`Version Published: ${version}`);
|
||||
};
|
||||
|
||||
const buildDist = series(clean, parallel(buildJs, compressJsVendor, buildCss, 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 publishFrontend = series(buildDist, uploadImages, upload, invalidate, logVersion);
|
||||
const stageFrontend = series(previewVersion, buildDist, uploadImagesPreview, uploadPreview, invalidatePreview);
|
||||
|
||||
export default publishFrontend;
|
||||
|
||||
export {
|
||||
buildDist,
|
||||
invalidate,
|
||||
stageFrontend,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
127
index.mjs
@@ -1,24 +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'));
|
||||
|
||||
@@ -45,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) {
|
||||
@@ -54,12 +71,8 @@ const index = (req, res) => {
|
||||
res.redirect(307, url.toString());
|
||||
return;
|
||||
}
|
||||
// return the standard page
|
||||
res.render('index', {
|
||||
production: false,
|
||||
version,
|
||||
OVERRIDES,
|
||||
});
|
||||
// return the EJS template page in development mode (serve files from server directory directly)
|
||||
renderIndex(req, res, false);
|
||||
};
|
||||
|
||||
const geoip = (req, res) => {
|
||||
@@ -78,20 +91,87 @@ const geoip = (req, res) => {
|
||||
res.json({});
|
||||
};
|
||||
|
||||
// debugging
|
||||
// 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'));
|
||||
// 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);
|
||||
app.use('/', express.static('./dist'));
|
||||
app.use('/music', express.static('./server/music', staticOptions));
|
||||
|
||||
// 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, () => {
|
||||
@@ -102,6 +182,7 @@ const server = app.listen(port, () => {
|
||||
const gracefulShutdown = () => {
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
28
nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
include mime.types;
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
add_header X-Weatherstar true always;
|
||||
|
||||
include /etc/nginx/includes/wsqs_redirect.conf;
|
||||
|
||||
location / {
|
||||
index 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;
|
||||
}
|
||||
}
|
||||
3860
package-lock.json
generated
31
package.json
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "ws4kp",
|
||||
"version": "5.21.7",
|
||||
"version": "6.5.9",
|
||||
"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,31 +28,37 @@
|
||||
"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": "^10.0.3",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-plugin-import": "^2.10.0",
|
||||
"fancy-log": "^2.0.0",
|
||||
"gulp": "^5.0.0",
|
||||
"gulp-awspublish": "^8.0.0",
|
||||
"gulp-awspublish": "^9.0.0",
|
||||
"gulp-concat": "^2.6.1",
|
||||
"gulp-ejs": "^5.1.0",
|
||||
"gulp-file": "^0.4.0",
|
||||
"gulp-html-minifier-terser": "^8.0.0",
|
||||
"gulp-rename": "^2.0.0",
|
||||
"gulp-s3-uploader": "^1.0.6",
|
||||
"gulp-sass": "^6.0.0",
|
||||
"gulp-sourcemaps": "^3.0.0",
|
||||
"gulp-terser": "^2.0.0",
|
||||
"luxon": "^3.0.0",
|
||||
"metar-taf-parser": "^9.0.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"sass": "^1.54.0",
|
||||
"suncalc": "^1.8.0",
|
||||
"swiped-events": "^1.1.4",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"webpack-stream": "^7.0.0",
|
||||
"gulp-html-minifier-terser": "^7.1.0"
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-stream": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.5.0",
|
||||
"ejs": "^3.1.5",
|
||||
"dotenv": "^17.0.1",
|
||||
"ejs": "^5.0.1",
|
||||
"express": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
502
proxy/cache.mjs
Normal 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
@@ -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'],
|
||||
});
|
||||
};
|
||||
BIN
server/images/backgrounds/7-wide.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
server/images/backgrounds/7.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
server/images/gimp/Full-Moon-Degraded.xcf
Normal file
BIN
server/images/gimp/No-Data.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
server/images/gimp/No-Data.xcf
Normal file
BIN
server/images/gimp/Radar Basemap5.xcf
Normal file
BIN
server/images/icons/current-conditions/No-Data.gif
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
server/images/icons/current-conditions/Smoke.gif
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
server/images/icons/moon-phases/Full-Moon-Degraded.gif
Normal file
|
After Width: | Height: | Size: 1009 B |
BIN
server/images/logos/app-icon-180.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
server/images/maps/radar-stretched-overlay.webp
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
server/images/maps/radar-stretched.webp
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
server/images/maps/radar-tiles/00-00.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
server/images/maps/radar-tiles/00-01.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
server/images/maps/radar-tiles/00-02.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
server/images/maps/radar-tiles/00-03.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
server/images/maps/radar-tiles/00-04.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
server/images/maps/radar-tiles/00-05.webp
Normal file
|
After Width: | Height: | Size: 464 B |
BIN
server/images/maps/radar-tiles/00-06.webp
Normal file
|
After Width: | Height: | Size: 50 B |
BIN
server/images/maps/radar-tiles/00-07.webp
Normal file
|
After Width: | Height: | Size: 50 B |
BIN
server/images/maps/radar-tiles/00-08.webp
Normal file
|
After Width: | Height: | Size: 50 B |
BIN
server/images/maps/radar-tiles/00-09.webp
Normal file
|
After Width: | Height: | Size: 50 B |
BIN
server/images/maps/radar-tiles/01-00.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
server/images/maps/radar-tiles/01-01.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
server/images/maps/radar-tiles/01-02.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
server/images/maps/radar-tiles/01-03.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
server/images/maps/radar-tiles/01-04.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
server/images/maps/radar-tiles/01-05.webp
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
server/images/maps/radar-tiles/01-06.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
server/images/maps/radar-tiles/01-07.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
server/images/maps/radar-tiles/01-08.webp
Normal file
|
After Width: | Height: | Size: 50 B |
BIN
server/images/maps/radar-tiles/01-09.webp
Normal file
|
After Width: | Height: | Size: 50 B |
BIN
server/images/maps/radar-tiles/02-00.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
server/images/maps/radar-tiles/02-01.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
server/images/maps/radar-tiles/02-02.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
server/images/maps/radar-tiles/02-03.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
server/images/maps/radar-tiles/02-04.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
server/images/maps/radar-tiles/02-05.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
server/images/maps/radar-tiles/02-06.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
server/images/maps/radar-tiles/02-07.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
server/images/maps/radar-tiles/02-08.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
server/images/maps/radar-tiles/02-09.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
server/images/maps/radar-tiles/03-00.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
server/images/maps/radar-tiles/03-01.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
server/images/maps/radar-tiles/03-02.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
server/images/maps/radar-tiles/03-03.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
server/images/maps/radar-tiles/03-04.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
server/images/maps/radar-tiles/03-05.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
server/images/maps/radar-tiles/03-06.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
server/images/maps/radar-tiles/03-07.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
server/images/maps/radar-tiles/03-08.webp
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
server/images/maps/radar-tiles/03-09.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
server/images/maps/radar-tiles/04-00.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
server/images/maps/radar-tiles/04-01.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
server/images/maps/radar-tiles/04-02.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
server/images/maps/radar-tiles/04-03.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
server/images/maps/radar-tiles/04-04.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
server/images/maps/radar-tiles/04-05.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
server/images/maps/radar-tiles/04-06.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
server/images/maps/radar-tiles/04-07.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
server/images/maps/radar-tiles/04-08.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
server/images/maps/radar-tiles/04-09.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
server/images/maps/radar-tiles/05-00.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
server/images/maps/radar-tiles/05-01.webp
Normal file
|
After Width: | Height: | Size: 63 KiB |