@seasketch/geoprocessing
Version:
Geoprocessing and reporting framework for SeaSketch 2.0
357 lines • 15.1 kB
JavaScript
import { GeoprocessingTaskStatus } from "../aws/tasks.js";
import { useState, useContext, useEffect } from "react";
import { useDeepEqualMemo } from "./useDeepEqualMemo.js";
import { ReportContext } from "../context/index.js";
import { runTask, finishTask } from "../clients/tasks.js";
import { genTaskCacheKey } from "../helpers/genTaskCacheKey.js";
import cloneDeep from "lodash/cloneDeep.js";
/** Local results cache */
const localCache = new Map();
/** */
let pendingRequests = [];
let pendingMetadataRequests = [];
let geoprocessingProjects = {};
/**
* Runs the given geoprocessing function for the current sketch, as defined by ReportContext
* During testing, useFunction will look for example output values in SketchContext.exampleOutputs
*/
export const useFunction = (
/** Title of geoprocessing function in this project to run. @todo support external project function */
functionTitle,
/** Additional runtime parameters from report client for geoprocessing function. Validation left to implementing function */
extraParams = {}) => {
const context = useContext(ReportContext);
if (!context) {
throw new Error("ReportContext not set.");
}
const [state, setState] = useState({
loading: true,
});
const memoizedExtraParams = useDeepEqualMemo(extraParams);
let socket;
useEffect(() => {
const abortController = new AbortController();
setState({
loading: true,
});
if (context.exampleOutputs) {
// This is test or storybook environment, so load example data
// or simulate loading and error states.
const data = context.exampleOutputs.find((output) => output.functionName === functionTitle);
if (!data && !context.simulateLoading && !context.simulateError) {
setState({
loading: false,
error: `Could not find example data for sketch "${context.sketchProperties.name}" and function "${functionTitle}". Run \`npm test\` to generate example outputs`,
});
}
// create a fake GeoprocessingTask record and set state, returning value
setState({
loading: context.simulateLoading ? context.simulateLoading : false,
task: {
id: "abc123",
location: "https://localhost/abc123",
service: "https://localhost",
logUriTemplate: "https://localhost/logs/abc123",
geometryUri: "https://localhost/geometry/abc123",
wss: "",
status: GeoprocessingTaskStatus.Completed,
startedAt: new Date().toISOString(),
duration: 0,
data: (data || {}).results,
error: context.simulateError ? context.simulateError : undefined,
estimate: 0,
},
error: context.simulateError ? context.simulateError : undefined,
});
}
else {
if (!context.projectUrl && context.geometryUri) {
setState({
loading: false,
error: "Client Error - ReportContext.projectUrl not specified",
});
return;
}
(async () => {
/** current geoprocessing project metadata (service manifest) */
let geoprocessingProject;
// get function endpoint url for running task
try {
geoprocessingProject = await getGeoprocessingProject(context.projectUrl, abortController.signal);
}
catch {
if (!abortController.signal.aborted) {
setState({
loading: false,
error: `Fetch of GeoprocessingProject metadata failed ${context.projectUrl}`,
});
return;
}
}
let url;
let executionMode;
if (functionTitle.startsWith("https:")) {
url = functionTitle;
}
else {
const service = geoprocessingProject.geoprocessingServices.find((s) => s.title === functionTitle);
if (!service) {
setState({
loading: false,
error: `Could not find service for function titled ${functionTitle}`,
});
return;
}
url = service.endpoint;
executionMode = service?.executionMode;
}
// fetch task/results
// TODO: Check for requiredProperties
const payload = {
geometryUri: context.geometryUri,
extraParams: JSON.stringify(extraParams), // will be url encoded automatically
};
if (context.sketchProperties.id && context.sketchProperties.updatedAt) {
const theCacheKey = genTaskCacheKey(functionTitle, context.sketchProperties, extraParams);
payload.cacheKey = theCacheKey;
}
// check local results cache. may already be available
if (payload.cacheKey) {
const task = localCache.get(payload.cacheKey);
if (task) {
setState({
loading: false,
task: task,
error: task.error,
});
return;
}
}
// check if a matching request is already in-flight and assign to it if so
let pendingRequest;
if (payload.cacheKey) {
const pending = pendingRequests.find((r) => r.cacheKey === payload.cacheKey &&
r.functionName === functionTitle);
if (pending) {
setState({
loading: true,
task: pending.task,
error: undefined,
});
pendingRequest = pending.promise;
}
}
// start the task
if (!pendingRequest) {
setState({
loading: true,
task: undefined,
error: undefined,
});
pendingRequest = runTask(url, payload, abortController.signal, false, false);
// add as pending request
if (payload.cacheKey) {
const pr = {
cacheKey: payload.cacheKey,
functionName: functionTitle,
promise: pendingRequest,
};
pendingRequests.push(pr);
// remove from pending once resolves
pendingRequest.finally(() => {
pendingRequests = pendingRequests.filter((p) => p !== pr);
});
}
}
// After task started, but still pending
pendingRequest
.then((task) => {
const currServiceName = task.service;
if (currServiceName &&
task.status !== "completed" &&
task.wss?.length > 0 &&
executionMode === "async") {
const sname = encodeURIComponent(currServiceName);
const ck = encodeURIComponent(payload.cacheKey || "");
const wssUrl = task.wss +
"?" +
"serviceName=" +
sname +
"&cacheKey=" +
ck +
"&fromClient=true";
// set up the socket (async only)
getSocket(task, wssUrl, setState, payload.cacheKey, url, payload, functionTitle, abortController, socket);
}
// check for invalid status
if (!task.status ||
!["pending", "completed", "failed"].includes(task.status)) {
setState({
loading: false,
task: task,
error: `Could not parse response from geoprocessing function.`,
});
return;
}
// set to pending state initially
setState({
loading: task.status === GeoprocessingTaskStatus.Pending,
task: task,
error: task.error,
});
// if task complete then load results
if (payload.cacheKey &&
task.status === GeoprocessingTaskStatus.Completed) {
localCache.set(payload.cacheKey, cloneDeep(task));
}
// if task pending then nothing more to do
if (task.status === GeoprocessingTaskStatus.Pending) {
if (task.wss?.length > 0) {
setState({
loading: true,
task: task,
error: task.error,
});
}
return;
}
})
.catch((error) => {
if (!abortController.signal.aborted) {
setState({
loading: false,
error: error.toString(),
});
}
});
})();
}
// Upon teardown any outstanding requests should be aborted. This useEffect
// cleanup function will run whenever geometryUri, sketchProperties, or
// functionTitle context vars are changed, or if the component is being
// unmounted
return () => {
abortController.abort();
};
}, [
context.geometryUri,
context.sketchProperties,
functionTitle,
memoizedExtraParams,
]);
return state;
};
/**
* Creates WebSocket at wss url that listens for task completion
*/
const getSocket = (task, wssUrl, setState, cacheKey, url, payload, currServiceName, abortController, socket) => {
if (socket === undefined) {
socket = new WebSocket(wssUrl);
}
// once socket open, check if task completed before it opened
socket.addEventListener("open", function () {
// Check local cache first
const task = localCache.get(cacheKey);
if (task) {
setState({
loading: false,
task: task,
error: task.error,
});
socket.close();
return;
}
// Check server-side cache next using checkCacheOnly true
const finishedRequest = runTask(url, payload, abortController.signal, true, true);
finishedRequest.then((finishedTask) => {
if (finishedTask.service === currServiceName) {
const ft = JSON.stringify(finishedTask);
//if not cached, you'll get a "NO_CACHE_HIT"
if (ft && finishedTask.id !== "NO_CACHE_HIT" && finishedTask.data) {
setState({
loading: false,
task: finishedTask,
error: finishedTask.error,
});
// task is complete so close the socket
socket.close(1000, currServiceName);
return;
}
}
});
});
// if task complete message received on socket (the only message type supported)
// then finish the task (because results aren't sent on the socket, too big)
socket.onmessage = function (event) {
const incomingData = JSON.parse(event.data);
if (event.data.timestamp) {
const nowTime = Date.now();
console.log(`timestamp ${currServiceName}: ${event.data.timestamp}`);
console.log(`received ${currServiceName}: ${nowTime}`);
console.log(`diff ${currServiceName}: ${nowTime - event.data.timestamp}`);
}
// check cache keys match. can have events for other reports appear if several are open at once.
if (incomingData.cacheKey === cacheKey &&
incomingData.serviceName === currServiceName) {
payload.cacheKey = cacheKey;
if (incomingData.failureMessage?.length > 0) {
task.error = incomingData.failureMessage;
task.status = GeoprocessingTaskStatus.Failed;
setState({
loading: false,
task: task,
error: task.error,
});
socket.close();
}
else {
finishTask(url, payload, abortController, setState, currServiceName, socket);
}
}
};
socket.addEventListener("close", function () {
//no op
});
socket.onerror = function () {
if (socket.url?.length > 0) {
setState({
loading: false,
error: "Error loading results. Unexpected socket error.",
});
}
};
return socket;
};
/**
* Fetches project metadata, aka service manifest at url
*/
const getGeoprocessingProject = async (url, signal) => {
// TODO: eventually handle updated duration
const pending = pendingMetadataRequests.find((r) => r.url === url);
if (pending) {
return pending.promise;
}
if (url in geoprocessingProjects) {
return geoprocessingProjects[url];
}
const request = fetch(url, { signal }).then(async (response) => {
const geoprocessingProject = await response.json();
if (signal.aborted) {
throw new Error("Aborted");
}
else {
geoprocessingProjects[url] = geoprocessingProject;
pendingMetadataRequests = pendingMetadataRequests.filter((r) => r.url !== url);
return geoprocessingProject;
}
});
pendingMetadataRequests.push({
url,
promise: request,
});
return request;
};
useFunction.reset = () => {
geoprocessingProjects = {};
};
//# sourceMappingURL=useFunction.js.map