Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,16 @@ __pycache__/
.env
*/logs/*
*/ncpartitioner-output/*
node_modules/
node_modules/
portal-meta/bccaqv2_u5.json
portal-meta/bccaqv2_u6.json
portal-meta/canada_mosaic.json
portal-meta/canesm5_m6.json
portal-meta/canesm5_u6.json
portal-meta/gridded_daily.json
portal-meta/mbcn.json
portal-meta/prism.json
portal-meta/vicgl.json
portal-prep/db-export.csv
.gitignore
portal-prep/pdp_min_max.csv
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@ menu rules from `portal_meta_builder/portals.py`.

## Scripts

### Run the frontend locally

Run the local viewer while proxying portal metadata, THREDDS, and
ncpartitioner requests to Beehive:

```bash
npm run dev
```

Then open <http://127.0.0.1:4173/pdp-next/>. Local files under `viewer/` are
served without caching, while `/pdp-next/portal-meta/`,
`/pdp-next/thredds/`, and `/pdp-next/ncpartitioner/` are fetched through the
same-origin development proxy.

The defaults can be overridden when needed:

```bash
PDP_DEV_PORT=8080 \
PDP_DEV_UPSTREAM=https://beehive.pacificclimate.org \
npm run dev
```

### Build hardlink mirror

```bash
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "pdp-next",
"private": true,
"scripts": {
"dev": "node scripts/dev-viewer.mjs",
"lint": "eslint 'viewer/js/**/*.js' 'viewer/js/*.js'",
"lint:fix": "eslint --fix 'viewer/js/**/*.js' 'viewer/js/*.js'",
"lint:ci": "eslint --max-warnings=999 'viewer/js/**/*.js' 'viewer/js/*.js'"
Expand All @@ -10,4 +11,4 @@
"@eslint/js": "^9.0.0",
"eslint": "^9.0.0"
}
}
}
146 changes: 146 additions & 0 deletions scripts/dev-viewer.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';

const repositoryRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
const viewerRoot = resolve(repositoryRoot, 'viewer');
const host = process.env.PDP_DEV_HOST || '127.0.0.1';
const port = Number.parseInt(process.env.PDP_DEV_PORT || '4173', 10);
const upstream = new URL(
process.env.PDP_DEV_UPSTREAM || 'https://beehive.pacificclimate.org',
);

if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid PDP_DEV_PORT: ${process.env.PDP_DEV_PORT}`);
}
if (!['http:', 'https:'].includes(upstream.protocol)) {
throw new Error('PDP_DEV_UPSTREAM must use http or https.');
}

const mimeTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};

function localAssetPath(pathname) {
if (pathname === '/pdp-next/' || pathname === '/pdp-next/pdp-next-viewer.html') {
return resolve(viewerRoot, 'pdp-next-viewer.html');
}
if (pathname === '/pdp-next/viewer.css') return resolve(viewerRoot, 'viewer.css');
if (pathname === '/pdp-next/favicon.ico') return resolve(viewerRoot, 'favicon.ico');

const assetMatch = pathname.match(/^\/pdp-next\/(js|styles)\/(.+)$/);
if (!assetMatch) return null;
let relativePath;
try {
relativePath = decodeURIComponent(`${assetMatch[1]}/${assetMatch[2]}`);
} catch {
return null;
}
const assetPath = resolve(viewerRoot, relativePath);
const viewerPrefix = `${viewerRoot}${sep}`;
return assetPath.startsWith(viewerPrefix) ? assetPath : null;
}

async function serveFile(request, response, filePath) {
try {
const details = await stat(filePath);
if (!details.isFile()) throw new Error('Not a file');
response.writeHead(200, {
'cache-control': 'no-store',
'content-length': details.size,
'content-type': mimeTypes[extname(filePath).toLowerCase()] || 'application/octet-stream',
});
if (request.method === 'HEAD') response.end();
else createReadStream(filePath).pipe(response);
} catch {
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
response.end('Not found\n');
}
}

function proxyRequest(request, response) {
const target = new URL(request.url, upstream);
const requestImpl = target.protocol === 'https:' ? httpsRequest : httpRequest;
const headers = {
...request.headers,
host: target.host,
'x-forwarded-host': request.headers.host || '',
'x-forwarded-proto': 'http',
};

const proxied = requestImpl(target, {
method: request.method,
headers,
}, (upstreamResponse) => {
const responseHeaders = { ...upstreamResponse.headers };
if (responseHeaders.location) {
responseHeaders.location = responseHeaders.location.replace(
upstream.origin,
`http://${request.headers.host}`,
);
}
response.writeHead(upstreamResponse.statusCode || 502, responseHeaders);
upstreamResponse.pipe(response);
});

proxied.on('error', (error) => {
if (response.headersSent) {
response.destroy(error);
return;
}
response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
response.end(`Could not reach ${upstream.origin}: ${error.message}\n`);
});
request.on('aborted', () => proxied.destroy());
request.pipe(proxied);
}

const server = createServer(async (request, response) => {
const requestUrl = new URL(request.url, `http://${request.headers.host || host}`);
if (requestUrl.pathname === '/') {
response.writeHead(302, { location: '/pdp-next/' });
response.end();
return;
}

const assetPath = localAssetPath(requestUrl.pathname);
if (assetPath) {
await serveFile(request, response, assetPath);
return;
}

const isBackendRoute = [
'/pdp-next/portal-meta/',
'/pdp-next/thredds/',
'/pdp-next/ncpartitioner/',
].some((prefix) => requestUrl.pathname.startsWith(prefix));
if (isBackendRoute) {
proxyRequest(request, response);
return;
}

if (requestUrl.pathname.startsWith('/pdp-next/')) {
await serveFile(request, response, resolve(viewerRoot, 'pdp-next-viewer.html'));
return;
}

response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
response.end('Not found\n');
});

server.listen(port, host, () => {
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
console.log(`PDP viewer: http://${displayHost}:${port}/pdp-next/`);
console.log(`Backend proxy: ${upstream.origin}`);
});
2 changes: 1 addition & 1 deletion thredds/Dockerfile.thredds
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM unidata/thredds-docker:5.6
FROM unidata/thredds-docker:5.9

# Bake in a minimal, generic catalog.
# Mount /data/* volumes at runtime.
Expand Down
6 changes: 6 additions & 0 deletions thredds/thredds-config/threddsConfig.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
<WCS>
<allow>true</allow>
</WCS>

<NCISO>
<ncmlAllow>true</ncmlAllow>
<uddcAllow>false</uddcAllow>
<isoAllow>true</isoAllow>
</NCISO>

<NetcdfSubsetService>
<allow>true</allow>
Expand Down
Loading
Loading