feat(shared): extract client/server pure helpers into @jerapah-flow/shared
Share profile merge, workflow filename/path helpers, HTTP_METHODS (with OPTIONS), and isPlainObject so web and server stop drifting. Core script sandboxes keep local isPlainObject copies. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"test:profiles": "node test/profiles-smoke.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jerapah-flow/shared": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.1111.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1111.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
|
||||
@@ -1,31 +1 @@
|
||||
/**
|
||||
* Shallow merge: step overlay keys replace profile defaults (including "").
|
||||
* Nested objects/arrays are replaced, not deep-merged.
|
||||
*
|
||||
* @param {unknown} profileConfig
|
||||
* @param {unknown} stepConfig
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function mergeProfileConfig(profileConfig, stepConfig) {
|
||||
const base =
|
||||
profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig)
|
||||
? { ...profileConfig }
|
||||
: {};
|
||||
if (stepConfig == null || typeof stepConfig !== "object" || Array.isArray(stepConfig)) {
|
||||
return base;
|
||||
}
|
||||
return { ...base, ...stepConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} config
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function configHasOverlay(config) {
|
||||
return (
|
||||
config != null &&
|
||||
typeof config === "object" &&
|
||||
!Array.isArray(config) &&
|
||||
Object.keys(config).length > 0
|
||||
);
|
||||
}
|
||||
export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared";
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
sendSuccessPage,
|
||||
} from "./http-trigger-auth.js";
|
||||
import { resolveConfigRefs } from "./config-refs.js";
|
||||
import { mergeProfileConfig } from "./profile-config.js";
|
||||
import { HTTP_METHODS, hasWorkflowTrigger, mergeProfileConfig } from "@jerapah-flow/shared";
|
||||
import { getProfilePlain } from "./profiles-store.js";
|
||||
import {
|
||||
buildFailureAlertData,
|
||||
@@ -46,17 +46,6 @@ import { ensureInitialRevision } from "./workflow-history.js";
|
||||
*/
|
||||
|
||||
const MAX_WORKFLOW_TRIGGER_DEPTH = 8;
|
||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
/**
|
||||
* @param {unknown} workflow
|
||||
*/
|
||||
function hasWorkflowTrigger(workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return false;
|
||||
const triggers = /** @type {{ triggers?: Array<{ type?: string }> }} */ (workflow)
|
||||
.triggers;
|
||||
return (triggers ?? []).some((t) => t?.type === "workflow");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} server
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
* Step return contract: `{ output, context?, skipRemaining? }`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
import { isPlainObject } from "@jerapah-flow/shared";
|
||||
|
||||
export { isPlainObject };
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
import yaml from "yaml";
|
||||
import {
|
||||
ensureWorkflowFilename,
|
||||
suggestCopyFilename,
|
||||
} from "@jerapah-flow/shared";
|
||||
import {
|
||||
newWorkflowFilename,
|
||||
workflowFileStem,
|
||||
} from "./workflow-normalize.js";
|
||||
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy human-readable copy name (kept for UI hints).
|
||||
* @param {string} file
|
||||
* @param {string[]} existingFiles
|
||||
*/
|
||||
export function suggestCopyFilename(file, existingFiles = []) {
|
||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
||||
const base = match ? match[1] : name;
|
||||
const ext = match ? match[2] : ".yaml";
|
||||
const existing = new Set(existingFiles);
|
||||
|
||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
||||
const root = copyMatch ? copyMatch[1] : base;
|
||||
const candidate = (i) =>
|
||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
||||
|
||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
||||
while (existing.has(candidate(n))) n += 1;
|
||||
return candidate(n);
|
||||
}
|
||||
export { ensureWorkflowFilename, suggestCopyFilename };
|
||||
|
||||
/**
|
||||
* UUID-based duplicate filename (default for new duplicates).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import jsonata from "jsonata";
|
||||
export { namespacedPath } from "@jerapah-flow/shared";
|
||||
|
||||
export const SET_STEP_SCRIPT = "set";
|
||||
|
||||
@@ -68,16 +69,6 @@ export async function evaluateJsonata(source, ctx) {
|
||||
return await result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an HTTP path under the owner namespace: /notify -> /u/alice/notify
|
||||
* @param {string} owner
|
||||
* @param {string} triggerPath
|
||||
*/
|
||||
export function namespacedPath(owner, triggerPath) {
|
||||
const cleaned = String(triggerPath).replace(/^\/+/, "");
|
||||
return `/u/${owner}/${cleaned}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} step
|
||||
* @returns {ParsedStep}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@jerapah-flow/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { isPlainObject } from "./is-plain-object.js";
|
||||
export { mergeProfileConfig, configHasOverlay } from "./profile-config.js";
|
||||
export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js";
|
||||
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js";
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shallow merge: step overlay keys replace profile defaults (including "").
|
||||
* Nested objects/arrays are replaced, not deep-merged.
|
||||
*
|
||||
* @param {unknown} profileConfig
|
||||
* @param {unknown} stepConfig
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function mergeProfileConfig(profileConfig, stepConfig) {
|
||||
const base =
|
||||
profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig)
|
||||
? { ...profileConfig }
|
||||
: {};
|
||||
if (stepConfig == null || typeof stepConfig !== "object" || Array.isArray(stepConfig)) {
|
||||
return base;
|
||||
}
|
||||
return { ...base, ...stepConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} config
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function configHasOverlay(config) {
|
||||
return (
|
||||
config != null &&
|
||||
typeof config === "object" &&
|
||||
!Array.isArray(config) &&
|
||||
Object.keys(config).length > 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function ensureWorkflowFilename(file) {
|
||||
const trimmed = String(file ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy human-readable copy name (kept for UI hints).
|
||||
* @param {string} file
|
||||
* @param {string[]} existingFiles
|
||||
*/
|
||||
export function suggestCopyFilename(file, existingFiles = []) {
|
||||
const name = ensureWorkflowFilename(file) || "workflow.yaml";
|
||||
const match = name.match(/^(.*?)(\.ya?ml)$/i);
|
||||
const base = match ? match[1] : name;
|
||||
const ext = match ? match[2] : ".yaml";
|
||||
const existing = new Set(existingFiles);
|
||||
|
||||
const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/);
|
||||
const root = copyMatch ? copyMatch[1] : base;
|
||||
const candidate = (i) =>
|
||||
i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`;
|
||||
|
||||
let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1;
|
||||
while (existing.has(candidate(n))) n += 1;
|
||||
return candidate(n);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||
|
||||
export function namespacedPath(owner, triggerPath) {
|
||||
const cleaned = String(triggerPath ?? "").replace(/^\/+/, "").trim();
|
||||
return `/u/${owner}/${cleaned}`;
|
||||
}
|
||||
|
||||
export function hasWorkflowTrigger(workflow) {
|
||||
if (!workflow || typeof workflow !== "object") return false;
|
||||
const triggers = workflow.triggers;
|
||||
if (!Array.isArray(triggers)) return false;
|
||||
return triggers.some((t) => String(t?.type ?? "").toLowerCase() === "workflow");
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jerapah-flow/shared": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
@@ -1,16 +1 @@
|
||||
export function mergeProfileConfig(base, overlay) {
|
||||
const a = base && typeof base === "object" && !Array.isArray(base) ? { ...base } : {};
|
||||
if (overlay == null || typeof overlay !== "object" || Array.isArray(overlay)) {
|
||||
return a;
|
||||
}
|
||||
return { ...a, ...overlay };
|
||||
}
|
||||
|
||||
export function configHasOverlay(config) {
|
||||
return (
|
||||
config != null &&
|
||||
typeof config === "object" &&
|
||||
!Array.isArray(config) &&
|
||||
Object.keys(config).length > 0
|
||||
);
|
||||
}
|
||||
export { mergeProfileConfig, configHasOverlay } from "@jerapah-flow/shared";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isPlainObject } from "@jerapah-flow/shared";
|
||||
|
||||
export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
|
||||
return { output: ctx.data ?? null, context: ctx.context ?? {} };
|
||||
}
|
||||
@@ -72,10 +74,6 @@ export function prettyJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function defaultsFromFields(fields) {
|
||||
if (!isPlainObject(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import {
|
||||
HTTP_METHODS,
|
||||
namespacedPath,
|
||||
hasWorkflowTrigger,
|
||||
} from "@jerapah-flow/shared";
|
||||
|
||||
const KNOWN_TOP = new Set(["name", "description", "enabled", "scripts", "triggers"]);
|
||||
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||
|
||||
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger };
|
||||
|
||||
export const NEW_WORKFLOW_YAML = `name: new workflow
|
||||
scripts:
|
||||
@@ -19,13 +25,6 @@ export function nextUiId(prefix = "ui") {
|
||||
return `${prefix}-${uidSeq}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function namespacedPath(owner, triggerPath) {
|
||||
const cleaned = String(triggerPath ?? "")
|
||||
.replace(/^\/+/, "")
|
||||
.trim();
|
||||
return `/u/${owner}/${cleaned}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {{ doc: WorkflowDoc | null, parseError: string | null }}
|
||||
@@ -162,10 +161,6 @@ export function newWorkflowTrigger() {
|
||||
};
|
||||
}
|
||||
|
||||
export function hasWorkflowTrigger(workflow) {
|
||||
return (workflow?.triggers ?? []).some((t) => String(t?.type ?? "").toLowerCase() === "workflow");
|
||||
}
|
||||
|
||||
export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
|
||||
return (workflows ?? []).filter((w) => {
|
||||
if (owner && w.owner !== owner) return false;
|
||||
@@ -174,8 +169,6 @@ export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export { HTTP_METHODS };
|
||||
|
||||
function readOnFailureWorkflow(raw) {
|
||||
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { isPlainObject } from "@jerapah-flow/shared";
|
||||
|
||||
const PREFIX = "jerapah-flow.workflowTestData:";
|
||||
|
||||
export function workflowTestStorageKey(owner, file) {
|
||||
return `${PREFIX}${owner}/${file}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Overlay saved test JSON on the schema/YAML seed. Stored object keys win. */
|
||||
export function overlayWorkflowTestData(seed, stored) {
|
||||
const base = isPlainObject(seed) ? seed : {};
|
||||
|
||||
Reference in New Issue
Block a user