Lightweight AIS-to-GeoJSON streaming API that fetches Automatic Identification System (AIS) data from NOAA, caches it locally, and serves filtered GeoJSON FeatureCollections.
AisStream provides a REST API to query historical AIS vessel tracking data. It streams data from NOAA's AIS Data Handler, filters by time range and bounding box, and returns GeoJSON features representing vessel positions.
Key features:
- Streaming: Efficiently streams large datasets without loading everything into memory
- Caching: Downloads and caches NOAA ZIP files locally for faster subsequent queries
- Filtering: Filters data by date/time range and geographic bounding box
- GeoJSON Output: Returns standard GeoJSON FeatureCollection with vessel properties
- Compression: Gzipped responses for reduced bandwidth
- Node.js 18+
- Yarn or npm
git clone <repository-url>
cd aisstream
npm install
npm run devThe server will start on http://localhost:8080.
docker build -t aisstream .
docker run --rm -p 8080:8080 -v /tmp/aiscache:/tmp/aiscache aisstreamMount /tmp/aiscache to persist cache between container runs.
Streams AIS data as NDJSON GeoJSON Features.
| Parameter | Type | Required | Description |
|---|---|---|---|
start |
string | Yes | Start date in YYYY-MM-DD format (e.g., 2023-01-01) |
bbox |
string | Yes | Bounding box as minLng,minLat,maxLng,maxLat (e.g., -180,-90,180,90) |
sample |
number | No | Sampling rate (default: 1, meaning every point; 2 means every other point) |
precision |
number | No | Coordinate precision (default: 6 decimal places) |
Returns a gzipped NDJSON stream of GeoJSON Features:
{"type":"Feature","geometry":{"type":"Point","coordinates":[-90.17964,38.65165]},"properties":{"MMSI":"368926035","BaseDateTime":"2023-01-01T00:00:05","LAT":"38.65165","LON":"-90.17964","SOG":"0.1","COG":"360.0","Heading":"511.0","VesselName":"KIMMSWICK","IMO":"","CallSign":"AENA","VesselType":"33","Status":"15","Length":"","Width":"","Draft":"","Cargo":"33","TransceiverClass":"A"}}
{"type":"Feature","geometry":{"type":"Point","coordinates":[-90.17964,38.65165]},"properties":{...}}
...Basic query for all vessels on January 1, 2023:
curl -s -H "Accept-Encoding: gzip" \
"http://localhost:8080/v2/nsjson?start=2023-01-01&bbox=-180,-90,180,90" \
| gunzip | head -c 10000Limited results with sampling:
curl -s -H "Accept-Encoding: gzip" \
"http://localhost:8080/v2/nsjson?start=2023-01-01&bbox=-76.4,36.7,-75.9,37.1&sample=10" \
| gunzip | wc -lSave to file:
curl -s -H "Accept-Encoding: gzip" \
"http://localhost:8080/v2/nsjson?start=2023-01-01&bbox=-180,-90,180,90" \
--output ais_data.ndjson.gzStreams AIS data as a GeoJSON FeatureCollection.
| Parameter | Type | Required | Description |
|---|---|---|---|
start |
string | Yes | Start date/time in ISO 8601 format (e.g., 2023-01-01T00:00:00Z) |
end |
string | Yes | End date/time in ISO 8601 format (e.g., 2023-01-01T23:59:59Z) |
bbox |
string | Yes | Bounding box as minLng,minLat,maxLng,maxLat (e.g., -180,-90,180,90) |
head |
number | No | Limit number of features returned (default: unlimited) |
Returns a gzipped GeoJSON FeatureCollection:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-90.17964, 38.65165]
},
"properties": {
"MMSI": "368926035",
"BaseDateTime": "2023-01-01T00:00:05",
"LAT": "38.65165",
"LON": "-90.17964",
"SOG": "0.1",
"COG": "360.0",
"Heading": "511.0",
"VesselName": "KIMMSWICK",
"IMO": "",
"CallSign": "AENA",
"VesselType": "33",
"Status": "15",
"Length": "",
"Width": "",
"Draft": "",
"Cargo": "33",
"TransceiverClass": "A"
}
}
]
}Basic query for all vessels on January 1, 2023:
curl -s -H "Accept-Encoding: gzip" \
"http://localhost:8080/v1/ais?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-180,-90,180,90" \
| gunzip | head -c 10000Limited results with head parameter:
curl -s -H "Accept-Encoding: gzip" \
"http://localhost:8080/v1/ais?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-76.4,36.7,-75.9,37.1&head=100" \
| gunzip | jq '.features | length'Save to file:
curl -s -H "Accept-Encoding: gzip" \
"http://localhost:8080/v1/ais?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-180,-90,180,90" \
--output ais_data.json.gzReturns Sentinel-2 satellite imagery metadata as a JSON object.
| Parameter | Type | Required | Description |
|---|---|---|---|
start |
string | Yes | Start date in ISO 8601 format (e.g., 2023-01-01T00:00:00Z) |
end |
string | Yes | End date in ISO 8601 format (e.g., 2023-01-01T23:59:59Z) |
bbox |
string | Yes | Bounding box as minLng,minLat,maxLng,maxLat (e.g., -180,-90,180,90) |
productType |
string | No | Product type: S2MSI2A (Level-2A), S2MSI1C (Level-1C), or ANY (default: ANY) |
cloudLt |
number | No | Maximum cloud cover percentage (e.g., 20 for ≤20% cloud cover) |
limit |
number | No | Maximum number of scenes to return (default: 6) |
frames |
number | No | Number of scenes to include in response (default: same as limit) |
save |
boolean | No | Save quicklook images locally (default: false) |
Returns a JSON object with Sentinel-2 scene metadata:
{
"count": 2,
"bbox": [-180, -90, 180, 90],
"start": "2023-01-01T00:00:00Z",
"end": "2023-01-01T23:59:59Z",
"productType": "ANY",
"scenes": [
{
"type": "scene",
"id": "S2B_MSIL2A_20230101T000000_N0214_R000_T01ABC_20230101T000000",
"datetime": "2023-01-01T00:00:00Z",
"cloud": 5.2,
"productType": "S2MSI2A",
"footprint": { "type": "Polygon", "coordinates": [[[ ... ]]] },
"mgrs": { "zone": "01", "latBand": "A", "grid": "BC" },
"bands": {
"B02": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/tiles/01/A/BC/2023/1/1/B02.tif",
"B03": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/tiles/01/A/BC/2023/1/1/B03.tif",
"B04": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/tiles/01/A/BC/2023/1/1/B04.tif",
"SCL": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/tiles/01/A/BC/2023/1/1/SCL.tif"
},
"tileTemplate": "http://localhost:8000/cog/tiles/{z}/{x}/{y}.png?expression=rgb(B04,B03,B02)&rescale=0,3000",
"quicklook": "/v1/s2/quicklook?href=https%3A//example.com/quicklook.jpg"
}
]
}Basic query for Sentinel-2 scenes on January 1, 2023:
curl "http://localhost:8080/v1/s2?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-180,-90,180,90"Limited results with cloud cover filter:
curl "http://localhost:8080/v1/s2?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-76.4,36.7,-75.9,37.1&cloudLt=20&limit=5"Save quicklooks locally:
curl "http://localhost:8080/v1/s2?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-180,-90,180,90&save=true"Streams Sentinel-2 satellite imagery metadata as NDJSON.
Same as /v1/s2, but limit defaults to 8.
Returns a stream of NDJSON lines, each a JSON object representing a scene, followed by a summary:
{"type":"scene","id":"S2B_MSIL2A_20230101T000000_N0214_R000_T01ABC_20230101T000000","datetime":"2023-01-01T00:00:00Z","cloud":5.2,"productType":"S2MSI2A","footprint":{...},"mgrs":{...},"bands":{...},"tileTemplate":"...","quicklook":"..."}
{"type":"scene",...}
{"type":"summary","count":2,"bbox":[-180,-90,180,90],"start":"2023-01-01T00:00:00Z","end":"2023-01-01T23:59:59Z","productType":"ANY"}Stream scenes to console:
curl "http://localhost:8080/v1/s2.ndjson?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-180,-90,180,90"Save to file:
curl "http://localhost:8080/v1/s2.ndjson?start=2023-01-01T00:00:00Z&end=2023-01-01T23:59:59Z&bbox=-76.4,36.7,-75.9,37.1&cloudLt=20" --output s2_data.ndjsonProxies quicklook images for Sentinel-2 scenes.
| Parameter | Type | Required | Description |
|---|---|---|---|
href |
string | Yes | URL of the quicklook image to proxy (e.g., from a scene's quicklook field) |
Returns the image file (e.g., JPEG) or a text error message.
Fetch a quicklook image:
curl "http://localhost:8080/v1/s2/quicklook?href=https%3A//example.com/quicklook.jpg" --output quicklook.jpgHere's a simple React component to fetch and display AIS data on a map:
import React, { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
function AISMap() {
const [vessels, setVessels] = useState([]);
const [loading, setLoading] = useState(false);
const fetchAISData = async () => {
setLoading(true);
try {
const response = await fetch(
'http://localhost:8080/v1/ais?' +
new URLSearchParams({
start: '2023-01-01T00:00:00Z',
end: '2023-01-01T23:59:59Z',
bbox: '-180,-90,180,90',
head: '1000'
}),
{
headers: {
'Accept-Encoding': 'gzip'
}
}
);
const data = await response.json();
setVessels(data.features || []);
} catch (error) {
console.error('Error fetching AIS data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAISData();
}, []);
return (
<div style={{ height: '500px' }}>
<button onClick={fetchAISData} disabled={loading}>
{loading ? 'Loading...' : 'Refresh Data'}
</button>
<MapContainer center={[0, 0]} zoom={2} style={{ height: '100%' }}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/>
{vessels.map((vessel, index) => (
<Marker
key={index}
position={[vessel.geometry.coordinates[1], vessel.geometry.coordinates[0]]}
>
<Popup>
<div>
<h3>{vessel.properties.VesselName || 'Unknown'}</h3>
<p>MMSI: {vessel.properties.MMSI}</p>
<p>Speed: {vessel.properties.SOG} knots</p>
<p>Course: {vessel.properties.COG}°</p>
<p>Time: {vessel.properties.BaseDateTime}</p>
</div>
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}
export default AISMap;- Create a new React app:
npx create-react-app ais-map
cd ais-map
npm install leaflet react-leaflet-
Replace
src/App.jswith the component above. -
Start the React app:
npm start- Ensure the AisStream API is running on
http://localhost:8080.
aisstream/
├── src/
│ ├── index.ts # Express app setup
│ ├── routes/
│ │ └── ais.ts # AIS API endpoint
│ └── lib/
│ ├── cache.ts # File caching utilities
│ ├── fetchDay.ts # Data fetching and streaming
│ ├── geometry.ts # Geographic utilities
│ └── urls.ts # URL generation for NOAA data
├── test/ # Jest tests
├── Dockerfile # Docker configuration
├── package.json # Dependencies and scripts
├── tsconfig.json # TypeScript configuration
├── jest.config.mjs # Jest configuration
├── eslint.config.mjs # ESLint configuration
└── README.md # This file
npm run dev- Start development server with hot reloadnpm run build- Build for productionnpm run start- Start production servernpm run lint- Run ESLintnpm run lint:fix- Fix ESLint issuesnpm test- Run Jest testsnpm run test:watch- Run tests in watch mode
Run the full test suite:
npm testTests include:
- API endpoint validation
- Data filtering logic
- Caching functionality
- Error handling
npm run lintPORT- Server port (default: 8080)MAX_CACHE_BYTES- Maximum cache size in bytes (default: 50GB)
AisStream caches downloaded NOAA ZIP files in /tmp/aiscache to improve performance for repeated queries. The cache:
- Stores raw ZIP files from NOAA
- Persists between server restarts (when using Docker volume mount)
- Has a configurable size limit
- Automatically reuses cached data for faster responses
Cache statistics can be monitored via the getCacheStats() function in src/lib/cache.ts.
AIS data is sourced from NOAA's Coastal Management Solution Program:
- URL pattern:
https://coast.noaa.gov/htdata/CMSP/AISDataHandler/{year}/AIS_{year}_{month}_{day}.zip - Data format: CSV files within ZIP archives
- Update frequency: Daily files
This project is licensed under the MIT License — see the LICENSE file for details.
AIS data courtesy of NOAA’s Coastal Management Solution Program. NOAA data are public domain under U.S. law.