@fractalq/client 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/examples/async-callback.js +88 -0
  4. package/examples/basic-usage.js +29 -0
  5. package/examples/image-upscaling.js +48 -0
  6. package/examples/pipeline-processing.js +51 -0
  7. package/examples/react-component.jsx +97 -0
  8. package/examples/smoke-config.mjs +26 -0
  9. package/index.d.ts +81 -0
  10. package/index.js +45 -0
  11. package/package.json +65 -0
  12. package/src/client/FractalQClient.js +132 -0
  13. package/src/client/authorization.js +11 -0
  14. package/src/client/build-direct-body.js +10 -0
  15. package/src/client/build-proxy-body.js +26 -0
  16. package/src/client/effective-job-id.js +9 -0
  17. package/src/client/execute-response.js +18 -0
  18. package/src/client/execute-url.js +9 -0
  19. package/src/client/http-error.js +12 -0
  20. package/src/client/resolve-base-url.js +39 -0
  21. package/src/client/temp-inputs.js +20 -0
  22. package/src/config/api-key.js +13 -0
  23. package/src/config/base-url.js +17 -0
  24. package/src/config/index.js +21 -0
  25. package/src/config/node-url.js +8 -0
  26. package/src/config/session-lookup.js +26 -0
  27. package/src/image/ImageProcessor.js +86 -0
  28. package/src/image/extract-upscaled-output.js +5 -0
  29. package/src/image/file-to-base64.js +15 -0
  30. package/src/image/index.js +2 -0
  31. package/src/image/prepare-image-input.js +11 -0
  32. package/src/image/resolve-callback-url.js +10 -0
  33. package/src/pipeline/PipelineProcessor.js +32 -0
  34. package/src/pipeline/apply-stage-result.js +9 -0
  35. package/src/pipeline/index.js +2 -0
  36. package/src/pipeline/merge-node-inputs.js +9 -0
  37. package/src/storage/firebase/image-to-blob.js +21 -0
  38. package/src/storage/firebase/index.js +2 -0
  39. package/src/storage/firebase/instance.js +52 -0
  40. package/src/storage/firebase/upload-task.js +31 -0
  41. package/src/storage/firebase/upload-to-firebase.js +33 -0
  42. package/src/storage/shared/base64-data-url-to-blob.js +24 -0
  43. package/src/storage/temp/TempFileManager.js +102 -0
  44. package/src/storage/temp/generate-filename.js +22 -0
  45. package/src/storage/temp/index.js +2 -0
  46. package/src/storage/temp/input-to-blob.js +18 -0
  47. package/src/storage/temp/mime-extension-map.js +12 -0
  48. package/src/storage/temp/upload-core.js +49 -0
  49. package/useFractalQ.d.ts +29 -0
  50. package/useFractalQ.js +126 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * FractalQ API client — execute nodes via your backend proxy or direct SignalQ URL.
3
+ *
4
+ * Request tracking: the **canonical** job / request id is created by **FractalQ (SignalQ)** and
5
+ * returned on the execute response (`result.jobId`) and in async callbacks. Optional `jobId` in
6
+ * `execute()` is only for **client-side** correlation (e.g. temp file paths, local logs) until
7
+ * the server responds; prefer the server id for durable tracking and analytics.
8
+ */
9
+
10
+ import { resolveExecuteBaseUrl } from './resolve-base-url.js';
11
+ import { buildExecuteUrl } from './execute-url.js';
12
+ import { resolveEffectiveJobId } from './effective-job-id.js';
13
+ import { applyTempFileInputs } from './temp-inputs.js';
14
+ import { buildProxyRequestBody } from './build-proxy-body.js';
15
+ import { buildDirectRequestBody } from './build-direct-body.js';
16
+ import { resolveAuthorizationHeader } from './authorization.js';
17
+ import { readExecuteErrorMessage } from './http-error.js';
18
+ import { shapeAsyncAcceptedResult, assertSyncSuccess } from './execute-response.js';
19
+
20
+ export class FractalQClient {
21
+ constructor(options = {}) {
22
+ this.useBackendProxy = options.useBackendProxy !== false;
23
+ this.baseUrl = resolveExecuteBaseUrl(this.useBackendProxy, options.baseUrl);
24
+ this.apiKey = options.apiKey || options.nodeServiceApiKey || null;
25
+ this.sessionApiKey = options.sessionApiKey || this.apiKey || null;
26
+ this.timeout = options.timeout || 120000;
27
+ this.firebaseStorage = options.firebaseStorage || null;
28
+ this.firebaseConfig = options.firebaseConfig || null;
29
+ /** @type {(() => Promise<string|null|undefined>)|null} */
30
+ this.authTokenProvider = options.authTokenProvider || null;
31
+ }
32
+
33
+ async execute(nodeId, inputs = {}, options = {}) {
34
+ const url = buildExecuteUrl({
35
+ useBackendProxy: this.useBackendProxy,
36
+ baseUrl: this.baseUrl,
37
+ nodeId,
38
+ apiKey: this.apiKey,
39
+ });
40
+
41
+ const timeout = options.timeout || this.timeout;
42
+ const {
43
+ callbackUrl,
44
+ async: asyncMode = false,
45
+ jobId: passedJobId,
46
+ useTempFiles = false,
47
+ fractalqVersion,
48
+ sessionApiKey: optionsSessionApiKey,
49
+ } = options;
50
+
51
+ const effectiveJobId = resolveEffectiveJobId(passedJobId);
52
+ const sessionApiKey = optionsSessionApiKey || this.sessionApiKey;
53
+ if (!sessionApiKey) {
54
+ throw new Error(
55
+ 'Session API key is required. Pass sessionApiKey in the constructor or in execute() options.'
56
+ );
57
+ }
58
+
59
+ let processedInputs = inputs;
60
+ if (useTempFiles && this.tempFiles && effectiveJobId) {
61
+ try {
62
+ processedInputs = await applyTempFileInputs(inputs, {
63
+ tempFiles: this.tempFiles,
64
+ effectiveJobId,
65
+ onProgress: options.onProgress,
66
+ });
67
+ } catch (tempError) {
68
+ console.warn('FractalQ: temp file upload failed, using original inputs:', tempError);
69
+ }
70
+ }
71
+
72
+ const requestBody = this.useBackendProxy
73
+ ? buildProxyRequestBody({
74
+ sessionApiKey,
75
+ processedInputs,
76
+ callbackUrl,
77
+ effectiveJobId,
78
+ asyncMode,
79
+ useTempFiles,
80
+ fractalqVersion,
81
+ options,
82
+ })
83
+ : buildDirectRequestBody({
84
+ processedInputs,
85
+ asyncMode,
86
+ callbackUrl,
87
+ fractalqVersion,
88
+ });
89
+
90
+ const controller = new AbortController();
91
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
92
+ const headers = { 'Content-Type': 'application/json' };
93
+
94
+ const authHeader = await resolveAuthorizationHeader(this.authTokenProvider);
95
+ if (authHeader) headers.Authorization = authHeader;
96
+
97
+ try {
98
+ const response = await fetch(url, {
99
+ method: 'POST',
100
+ headers,
101
+ body: JSON.stringify(requestBody),
102
+ signal: controller.signal,
103
+ credentials: 'include',
104
+ });
105
+ clearTimeout(timeoutId);
106
+
107
+ if (!response.ok) {
108
+ throw await readExecuteErrorMessage(response);
109
+ }
110
+
111
+ const result = await response.json();
112
+
113
+ if (asyncMode && callbackUrl && result.async) {
114
+ return shapeAsyncAcceptedResult(result, { effectiveJobId });
115
+ }
116
+
117
+ return assertSyncSuccess(result);
118
+ } catch (error) {
119
+ clearTimeout(timeoutId);
120
+ if (error.name === 'AbortError') {
121
+ throw new Error(`FractalQ API request timed out after ${timeout}ms`);
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ async executeNode(nodeId, inputs = {}, options = {}) {
128
+ return this.execute(nodeId, inputs, options);
129
+ }
130
+ }
131
+
132
+ export default FractalQClient;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Optional Bearer token from host-provided authTokenProvider.
3
+ */
4
+ export async function resolveAuthorizationHeader(authTokenProvider) {
5
+ if (!authTokenProvider) return null;
6
+ try {
7
+ const t = await authTokenProvider();
8
+ if (t) return `Bearer ${t}`;
9
+ } catch (_) {}
10
+ return null;
11
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * JSON body for direct SignalQ execute (no backend proxy).
3
+ */
4
+ export function buildDirectRequestBody({ processedInputs, asyncMode, callbackUrl, fractalqVersion }) {
5
+ return {
6
+ ...processedInputs,
7
+ ...(asyncMode && callbackUrl ? { _callbackUrl: callbackUrl } : {}),
8
+ ...(fractalqVersion ? { _fractalqVersion: fractalqVersion } : {}),
9
+ };
10
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * JSON body for app proxy (e.g. POST /api/fractalq/execute).
3
+ */
4
+ export function buildProxyRequestBody({
5
+ sessionApiKey,
6
+ processedInputs,
7
+ callbackUrl,
8
+ effectiveJobId,
9
+ asyncMode,
10
+ useTempFiles,
11
+ fractalqVersion,
12
+ options,
13
+ }) {
14
+ return {
15
+ sessionApiKey,
16
+ inputs: { ...processedInputs },
17
+ options: {
18
+ callbackUrl,
19
+ jobId: effectiveJobId,
20
+ async: asyncMode,
21
+ useTempFiles,
22
+ fractalqVersion,
23
+ ...options,
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Client-side correlation id until FractalQ returns canonical jobId.
3
+ */
4
+ export function resolveEffectiveJobId(passedJobId) {
5
+ if (typeof passedJobId === 'string' && passedJobId.trim()) {
6
+ return passedJobId.trim();
7
+ }
8
+ return `job-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
9
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Normalize execute() JSON result (async vs sync).
3
+ */
4
+ export function shapeAsyncAcceptedResult(result, { effectiveJobId }) {
5
+ return {
6
+ success: true,
7
+ async: true,
8
+ jobId: result.jobId || effectiveJobId,
9
+ message: result.message || 'Processing started. Results will be sent to callback URL.',
10
+ };
11
+ }
12
+
13
+ export function assertSyncSuccess(result) {
14
+ if (!result.success) {
15
+ throw new Error(result.error || 'FractalQ API execution failed');
16
+ }
17
+ return result;
18
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Full URL for POST execute.
3
+ */
4
+ export function buildExecuteUrl({ useBackendProxy, baseUrl, nodeId, apiKey }) {
5
+ if (useBackendProxy) {
6
+ return `${baseUrl}/execute`;
7
+ }
8
+ return `${baseUrl}/${nodeId || apiKey}/execute`;
9
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Parse error payload from non-OK execute response.
3
+ */
4
+ export async function readExecuteErrorMessage(response) {
5
+ const errorText = await response.text();
6
+ let message = errorText;
7
+ try {
8
+ const errJson = JSON.parse(errorText);
9
+ if (errJson.error) message = errJson.error;
10
+ } catch (_) {}
11
+ return new Error(`FractalQ API error (${response.status}): ${message}`);
12
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Resolve HTTP base for execute().
3
+ * - Proxy mode: relative path default `/api/fractalq`, or options.baseUrl from the host app.
4
+ * - Direct mode: no URL is hardcoded — pass options.baseUrl or set FRACTALQ_NODE_BASE_URL /
5
+ * NEXT_PUBLIC_FRACTALQ_BASE_URL in the host environment (your app / .env.local), never inside this package.
6
+ */
7
+
8
+ function trimBase(s) {
9
+ return String(s).trim().replace(/\/+$/, '');
10
+ }
11
+
12
+ export function resolveExecuteBaseUrl(useBackendProxy, explicitBaseUrl) {
13
+ const trimmed =
14
+ typeof explicitBaseUrl === 'string' && explicitBaseUrl.trim() ? trimBase(explicitBaseUrl) : null;
15
+
16
+ if (useBackendProxy) {
17
+ return trimmed || '/api/fractalq';
18
+ }
19
+
20
+ if (trimmed) {
21
+ return trimmed;
22
+ }
23
+
24
+ const fromEnv =
25
+ typeof process !== 'undefined'
26
+ ? (process.env.FRACTALQ_NODE_BASE_URL || process.env.NEXT_PUBLIC_FRACTALQ_BASE_URL || '')
27
+ .trim()
28
+ : '';
29
+ if (fromEnv) {
30
+ return trimBase(fromEnv);
31
+ }
32
+
33
+ throw new Error(
34
+ 'FractalQClient: direct mode (useBackendProxy: false) needs a base URL. ' +
35
+ 'Pass options.baseUrl (e.g. https://fractalq.com/api/node/api), or set FRACTALQ_NODE_BASE_URL ' +
36
+ 'or NEXT_PUBLIC_FRACTALQ_BASE_URL in your application environment. ' +
37
+ 'This package does not ship a default host (no embedded localhost or production URL).'
38
+ );
39
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Replace File/Blob/URL-ish strings with temp storage URLs when tempFiles is configured.
3
+ */
4
+ export async function applyTempFileInputs(inputs, { tempFiles, effectiveJobId, onProgress }) {
5
+ if (!tempFiles || !effectiveJobId) {
6
+ return inputs;
7
+ }
8
+ const processedInputs = { ...inputs };
9
+ for (const [key, value] of Object.entries(inputs)) {
10
+ if (
11
+ value instanceof File ||
12
+ value instanceof Blob ||
13
+ (typeof value === 'string' && (value.startsWith('data:') || value.startsWith('http')))
14
+ ) {
15
+ const tempFile = await tempFiles.uploadTempFile(value, effectiveJobId, null, onProgress);
16
+ processedInputs[key] = tempFile.url;
17
+ }
18
+ }
19
+ return processedInputs;
20
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Validate node / session API key passed by the host app.
3
+ */
4
+
5
+ export function getFractalQApiKey(apiKey) {
6
+ if (!apiKey || typeof apiKey !== 'string' || !apiKey.trim()) {
7
+ throw new Error(
8
+ 'FractalQ API key is required. Pass it from your application (host env, DB, or vault). ' +
9
+ '@fractalq/client does not load API keys from environment variables.'
10
+ );
11
+ }
12
+ return apiKey.trim();
13
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Resolve SignalQ / FractalQ host base URL (npm-safe; no secret env reads).
3
+ */
4
+
5
+ export function getSignalQBaseUrl(override) {
6
+ if (override && typeof override === 'string' && override.trim()) {
7
+ return override.replace(/\/+$/, '');
8
+ }
9
+ if (typeof process !== 'undefined' && process.env?.SIGNALQ_BASE_URL) {
10
+ const configured = String(process.env.SIGNALQ_BASE_URL).trim();
11
+ const looksLocal = /localhost|127\.0\.0\.1/i.test(configured);
12
+ if (!(process.env.NODE_ENV === 'production' && looksLocal)) {
13
+ return configured;
14
+ }
15
+ }
16
+ return 'https://fractalq.com';
17
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * FractalQ Client — configuration (npm-safe).
3
+ */
4
+
5
+ export { getSignalQBaseUrl } from './base-url.js';
6
+ export { getFractalQApiKey } from './api-key.js';
7
+ export { getSignalQApiUrl } from './node-url.js';
8
+ export { getApiKeyBySessionId, getSignalQApiUrlBySessionId } from './session-lookup.js';
9
+
10
+ import { getSignalQBaseUrl } from './base-url.js';
11
+ import { getFractalQApiKey } from './api-key.js';
12
+ import { getSignalQApiUrl } from './node-url.js';
13
+ import { getApiKeyBySessionId, getSignalQApiUrlBySessionId } from './session-lookup.js';
14
+
15
+ export default {
16
+ getSignalQBaseUrl,
17
+ getFractalQApiKey,
18
+ getSignalQApiUrl,
19
+ getApiKeyBySessionId,
20
+ getSignalQApiUrlBySessionId,
21
+ };
@@ -0,0 +1,8 @@
1
+ import { getSignalQBaseUrl } from './base-url.js';
2
+ import { getFractalQApiKey } from './api-key.js';
3
+
4
+ export function getSignalQApiUrl(apiKey, baseUrl) {
5
+ const key = getFractalQApiKey(apiKey);
6
+ const base = getSignalQBaseUrl(baseUrl);
7
+ return `${base}/api/node/api/${key}/execute`;
8
+ }
@@ -0,0 +1,26 @@
1
+ import { getSignalQBaseUrl } from './base-url.js';
2
+ import { getSignalQApiUrl } from './node-url.js';
3
+
4
+ export async function getApiKeyBySessionId(sessionId, opts = {}) {
5
+ const base = getSignalQBaseUrl(opts.baseUrl);
6
+ const lookupUrl = `${base}/api/node/api?sessionId=${sessionId}`;
7
+ const headers = { 'Content-Type': 'application/json' };
8
+ if (opts.accountApiKey && String(opts.accountApiKey).trim()) {
9
+ headers.Authorization = `Bearer ${opts.accountApiKey.trim()}`;
10
+ }
11
+ const response = await fetch(lookupUrl, { method: 'GET', headers });
12
+ if (!response.ok) {
13
+ const err = await response.json().catch(() => ({ message: 'Failed to look up session' }));
14
+ throw new Error(err.message || `Session lookup failed: ${response.status}`);
15
+ }
16
+ const result = await response.json();
17
+ if (!result.success) {
18
+ throw new Error(result.message || 'Session lookup failed');
19
+ }
20
+ return result.data.apiKey;
21
+ }
22
+
23
+ export async function getSignalQApiUrlBySessionId(sessionId, opts = {}) {
24
+ const apiKey = await getApiKeyBySessionId(sessionId, opts);
25
+ return getSignalQApiUrl(apiKey, opts.baseUrl);
26
+ }
@@ -0,0 +1,86 @@
1
+ import { uploadToFirebase } from '../storage/firebase/index.js';
2
+ import { prepareImageInputForExecute } from './prepare-image-input.js';
3
+ import { resolveUpscaleCallbackUrl } from './resolve-callback-url.js';
4
+ import { extractUpscaledImageData } from './extract-upscaled-output.js';
5
+
6
+ export class ImageProcessor {
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+
11
+ async upscaleAndUpload(image, options = {}) {
12
+ const {
13
+ nodeId = null,
14
+ uploadPath = 'upscaled-images',
15
+ fileName = null,
16
+ onProgress = null,
17
+ inputs = {},
18
+ async: asyncMode = false,
19
+ callbackUrl = null,
20
+ jobId = null,
21
+ wokenmemeBaseUrl = null,
22
+ } = options;
23
+
24
+ try {
25
+ const imageInput = await prepareImageInputForExecute(image);
26
+ const finalCallbackUrl = resolveUpscaleCallbackUrl({
27
+ callbackUrl,
28
+ async: asyncMode,
29
+ wokenmemeBaseUrl,
30
+ });
31
+ const finalJobId = jobId || (asyncMode ? `job-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` : null);
32
+
33
+ if (onProgress) onProgress({ step: 'upscaling', progress: 0.3 });
34
+
35
+ const nodeInputs = { input_0: imageInput, ...inputs };
36
+ const executeOptions = {
37
+ ...options,
38
+ async: asyncMode,
39
+ callbackUrl: finalCallbackUrl,
40
+ jobId: finalJobId,
41
+ };
42
+
43
+ const upscaleResult = await this.client.execute(nodeId, nodeInputs, executeOptions);
44
+
45
+ if (asyncMode && upscaleResult.async) {
46
+ if (onProgress) onProgress({ step: 'processing', progress: 0.5 });
47
+ return {
48
+ jobId: upscaleResult.jobId || finalJobId,
49
+ async: true,
50
+ message: upscaleResult.message || 'Processing started. Results will be sent to callback URL.',
51
+ callbackUrl: finalCallbackUrl,
52
+ };
53
+ }
54
+
55
+ if (onProgress) onProgress({ step: 'upscaling', progress: 0.6 });
56
+
57
+ const upscaledImageData = extractUpscaledImageData(upscaleResult);
58
+ if (!upscaledImageData) {
59
+ throw new Error('No upscaled image data returned from API');
60
+ }
61
+
62
+ if (onProgress) onProgress({ step: 'uploading', progress: 0.7 });
63
+
64
+ const uploadedUrl = await uploadToFirebase({
65
+ imageData: upscaledImageData,
66
+ path: uploadPath,
67
+ fileName: fileName || `upscaled-${Date.now()}.png`,
68
+ onProgress: (progress) => {
69
+ if (onProgress) {
70
+ onProgress({ step: 'uploading', progress: 0.7 + progress * 0.3 });
71
+ }
72
+ },
73
+ firebaseStorage: this.client.firebaseStorage,
74
+ firebaseConfig: this.client.firebaseConfig,
75
+ });
76
+
77
+ if (onProgress) onProgress({ step: 'complete', progress: 1.0 });
78
+ return uploadedUrl;
79
+ } catch (error) {
80
+ console.error('FractalQ upscale and upload error:', error);
81
+ throw error;
82
+ }
83
+ }
84
+ }
85
+
86
+ export default ImageProcessor;
@@ -0,0 +1,5 @@
1
+ export function extractUpscaledImageData(upscaleResult) {
2
+ return (
3
+ upscaleResult.data?.output_0 || upscaleResult.data?.image || upscaleResult.data || null
4
+ );
5
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Read File/Blob as raw base64 payload (no data: prefix).
3
+ */
4
+ export function fileToBase64Data(file) {
5
+ return new Promise((resolve, reject) => {
6
+ const reader = new FileReader();
7
+ reader.onload = () => {
8
+ const base64 = reader.result;
9
+ const base64Data = base64.includes(',') ? base64.split(',')[1] : base64;
10
+ resolve(base64Data);
11
+ };
12
+ reader.onerror = reject;
13
+ reader.readAsDataURL(file);
14
+ });
15
+ }
@@ -0,0 +1,2 @@
1
+ export { ImageProcessor } from './ImageProcessor.js';
2
+ export { default } from './ImageProcessor.js';
@@ -0,0 +1,11 @@
1
+ import { fileToBase64Data } from './file-to-base64.js';
2
+
3
+ export async function prepareImageInputForExecute(image) {
4
+ if (typeof image === 'string') {
5
+ return image;
6
+ }
7
+ if (image instanceof File || image instanceof Blob) {
8
+ return fileToBase64Data(image);
9
+ }
10
+ throw new Error('Invalid image input. Must be File, Blob, or URL string.');
11
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Default async callback URL in the browser when host does not pass one.
3
+ */
4
+ export function resolveUpscaleCallbackUrl({ callbackUrl, async: asyncMode, wokenmemeBaseUrl }) {
5
+ if (!asyncMode) return callbackUrl;
6
+ if (callbackUrl) return callbackUrl;
7
+ if (typeof window === 'undefined') return null;
8
+ const baseUrl = wokenmemeBaseUrl || window.location.origin;
9
+ return `${baseUrl}/api/fractalq/callback`;
10
+ }
@@ -0,0 +1,32 @@
1
+ import { mergeNodeInputs } from './merge-node-inputs.js';
2
+ import { nextStageData } from './apply-stage-result.js';
3
+
4
+ export class PipelineProcessor {
5
+ constructor(client) {
6
+ this.client = client;
7
+ }
8
+
9
+ async execute(nodePipeline, options = {}) {
10
+ let currentData = options.initialData || {};
11
+
12
+ for (let i = 0; i < nodePipeline.length; i++) {
13
+ const { nodeId, inputs = {}, transform } = nodePipeline[i];
14
+ const nodeInputs = mergeNodeInputs(currentData, inputs);
15
+
16
+ if (options.onProgress) {
17
+ options.onProgress({
18
+ step: `node-${i + 1}`,
19
+ progress: (i + 1) / nodePipeline.length,
20
+ nodeId,
21
+ });
22
+ }
23
+
24
+ const result = await this.client.execute(nodeId, nodeInputs, options);
25
+ currentData = nextStageData(result, transform);
26
+ }
27
+
28
+ return { success: true, data: currentData };
29
+ }
30
+ }
31
+
32
+ export default PipelineProcessor;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Apply optional transform to execute() result data for the next stage.
3
+ */
4
+ export function nextStageData(result, transform) {
5
+ if (transform && typeof transform === 'function') {
6
+ return transform(result.data);
7
+ }
8
+ return result.data;
9
+ }
@@ -0,0 +1,2 @@
1
+ export { PipelineProcessor } from './PipelineProcessor.js';
2
+ export { default } from './PipelineProcessor.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Merge accumulated pipeline data with the next node's explicit inputs.
3
+ */
4
+ export function mergeNodeInputs(currentData, inputs) {
5
+ return {
6
+ ...currentData,
7
+ ...inputs,
8
+ };
9
+ }
@@ -0,0 +1,21 @@
1
+ import { base64DataUrlToBlob } from '../shared/base64-data-url-to-blob.js';
2
+
3
+ /**
4
+ * Normalize imageData (blob, file, URL string, base64) to Blob for upload.
5
+ */
6
+ export async function imageDataToBlob(imageData) {
7
+ if (imageData instanceof Blob || imageData instanceof File) {
8
+ return imageData;
9
+ }
10
+ if (typeof imageData === 'string') {
11
+ if (imageData.startsWith('data:')) {
12
+ return base64DataUrlToBlob(imageData);
13
+ }
14
+ if (imageData.startsWith('http://') || imageData.startsWith('https://')) {
15
+ const response = await fetch(imageData);
16
+ return response.blob();
17
+ }
18
+ return base64DataUrlToBlob(`data:image/png;base64,${imageData}`);
19
+ }
20
+ throw new Error('Invalid image data format');
21
+ }
@@ -0,0 +1,2 @@
1
+ export { getStorageInstance } from './instance.js';
2
+ export { uploadToFirebase, uploadFileToFirebase } from './upload-to-firebase.js';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resolve Firebase Storage instance from explicit options or env (browser).
3
+ */
4
+
5
+ let storage = null;
6
+
7
+ export const getStorageInstance = (options = {}) => {
8
+ if (options.firebaseStorage) {
9
+ return options.firebaseStorage;
10
+ }
11
+ if (storage) return storage;
12
+
13
+ if (typeof window === 'undefined') {
14
+ return null;
15
+ }
16
+
17
+ if (options.firebaseConfig) {
18
+ try {
19
+ const { initializeApp } = require('firebase/app');
20
+ const { getStorage } = require('firebase/storage');
21
+ const app = initializeApp(options.firebaseConfig);
22
+ storage = getStorage(app);
23
+ return storage;
24
+ } catch (error) {
25
+ console.warn('Failed to initialize Firebase from provided config:', error);
26
+ }
27
+ }
28
+
29
+ try {
30
+ const { initializeApp, getApps } = require('firebase/app');
31
+ const { getStorage } = require('firebase/storage');
32
+ const apps = getApps();
33
+ if (apps.length === 0) {
34
+ const firebaseConfig = {
35
+ apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
36
+ authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
37
+ projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
38
+ storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
39
+ messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
40
+ appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
41
+ };
42
+ const app = initializeApp(firebaseConfig);
43
+ storage = getStorage(app);
44
+ } else {
45
+ storage = getStorage(apps[0]);
46
+ }
47
+ } catch (fallbackError) {
48
+ console.warn('Firebase Storage not available:', fallbackError);
49
+ }
50
+
51
+ return storage;
52
+ };