Skip to content

Commit 508c077

Browse files
push
1 parent 6c5eeda commit 508c077

File tree

3 files changed

+414
-1
lines changed

3 files changed

+414
-1
lines changed
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
import path from 'path';
2+
import os from 'os';
3+
import { JSDOM, VirtualConsole } from 'jsdom';
4+
import fs from 'fs-extra';
5+
import type { Elb } from '@walkeros/core';
6+
import {
7+
createCommandLogger,
8+
createLogger,
9+
executeCommand,
10+
getErrorMessage,
11+
buildCommonDockerArgs,
12+
type Logger,
13+
} from '../../core/index.js';
14+
import {
15+
loadJsonConfig,
16+
loadJsonFromSource,
17+
loadBundleConfig,
18+
} from '../../config/index.js';
19+
import { bundle } from '../bundle/index.js';
20+
import type { PushCommandOptions, PushResult } from './types.js';
21+
22+
/**
23+
* CLI command handler for push command
24+
*/
25+
export async function pushCommand(options: PushCommandOptions): Promise<void> {
26+
const logger = createCommandLogger(options);
27+
28+
// Build Docker args
29+
const dockerArgs = buildCommonDockerArgs(options);
30+
dockerArgs.push('--event', options.event);
31+
if (options.env) dockerArgs.push('--env', options.env);
32+
33+
await executeCommand(
34+
async () => {
35+
const startTime = Date.now();
36+
37+
try {
38+
// Step 1: Load event
39+
logger.info('📥 Loading event...');
40+
const event = await loadJsonFromSource(options.event, {
41+
name: 'event',
42+
});
43+
44+
// Validate event format
45+
if (
46+
!event ||
47+
typeof event !== 'object' ||
48+
!('name' in event) ||
49+
typeof event.name !== 'string'
50+
) {
51+
throw new Error(
52+
'Event must be an object with a "name" property (string)',
53+
);
54+
}
55+
56+
// Warn about event naming format
57+
if (!event.name.includes(' ')) {
58+
logger.warn(
59+
`Event name "${event.name}" should follow "ENTITY ACTION" format (e.g., "page view")`,
60+
);
61+
}
62+
63+
// Step 2: Load config
64+
logger.info('📦 Loading flow configuration...');
65+
const configPath = path.resolve(options.config);
66+
const rawConfig = await loadJsonConfig(configPath);
67+
const { flowConfig, buildOptions, environment, isMultiEnvironment } =
68+
loadBundleConfig(rawConfig, {
69+
configPath: options.config,
70+
environment: options.env,
71+
logger,
72+
});
73+
74+
const platform = flowConfig.platform;
75+
76+
// Step 3: Bundle to temp file
77+
logger.info('🔨 Bundling flow configuration...');
78+
const tempPath = path.join(
79+
os.tmpdir(),
80+
`walkeros-push-${Date.now()}-${Math.random().toString(36).slice(2, 9)}.${platform === 'web' ? 'js' : 'mjs'}`,
81+
);
82+
83+
const configWithOutput = {
84+
flow: flowConfig,
85+
build: {
86+
...buildOptions,
87+
output: tempPath,
88+
// Web uses IIFE for browser-like execution, server uses ESM
89+
format: platform === 'web' ? ('iife' as const) : ('esm' as const),
90+
platform:
91+
platform === 'web' ? ('browser' as const) : ('node' as const),
92+
...(platform === 'web' && {
93+
windowCollector: 'collector',
94+
windowElb: 'elb',
95+
}),
96+
},
97+
};
98+
99+
await bundle(configWithOutput, {
100+
cache: true,
101+
verbose: options.verbose,
102+
silent: !options.verbose,
103+
});
104+
105+
logger.debug(`Bundle created: ${tempPath}`);
106+
107+
// Step 4: Execute based on platform
108+
let result: PushResult;
109+
110+
if (platform === 'web') {
111+
logger.info('🌐 Executing in web environment (JSDOM)...');
112+
result = await executeWebPush(tempPath, event, logger);
113+
} else if (platform === 'server') {
114+
logger.info('🖥️ Executing in server environment (Node.js)...');
115+
result = await executeServerPush(tempPath, event, logger);
116+
} else {
117+
throw new Error(`Unsupported platform: ${platform}`);
118+
}
119+
120+
// Step 5: Output results
121+
const duration = Date.now() - startTime;
122+
123+
if (options.json) {
124+
// JSON output
125+
const outputLogger = createLogger({ silent: false, json: false });
126+
outputLogger.log(
127+
'white',
128+
JSON.stringify(
129+
{
130+
success: result.success,
131+
event: result.elbResult,
132+
duration,
133+
},
134+
null,
135+
2,
136+
),
137+
);
138+
} else {
139+
// Standard output
140+
if (result.success) {
141+
logger.success('✅ Event pushed successfully');
142+
if (result.elbResult && typeof result.elbResult === 'object') {
143+
const pushResult = result.elbResult as unknown as Record<
144+
string,
145+
unknown
146+
>;
147+
if ('id' in pushResult && pushResult.id) {
148+
logger.info(` Event ID: ${pushResult.id}`);
149+
}
150+
if ('entity' in pushResult && pushResult.entity) {
151+
logger.info(` Entity: ${pushResult.entity}`);
152+
}
153+
if ('action' in pushResult && pushResult.action) {
154+
logger.info(` Action: ${pushResult.action}`);
155+
}
156+
}
157+
logger.info(` Duration: ${duration}ms`);
158+
} else {
159+
logger.error(`❌ Push failed: ${result.error}`);
160+
process.exit(1);
161+
}
162+
}
163+
164+
// Cleanup
165+
try {
166+
await fs.remove(tempPath);
167+
} catch {
168+
// Ignore cleanup errors
169+
}
170+
} catch (error) {
171+
const duration = Date.now() - startTime;
172+
const errorMessage = getErrorMessage(error);
173+
174+
if (options.json) {
175+
const outputLogger = createLogger({ silent: false, json: false });
176+
outputLogger.log(
177+
'white',
178+
JSON.stringify(
179+
{
180+
success: false,
181+
error: errorMessage,
182+
duration,
183+
},
184+
null,
185+
2,
186+
),
187+
);
188+
} else {
189+
logger.error(`❌ Push command failed: ${errorMessage}`);
190+
}
191+
192+
process.exit(1);
193+
}
194+
},
195+
'push',
196+
dockerArgs,
197+
options,
198+
logger,
199+
options.config,
200+
);
201+
}
202+
203+
/**
204+
* Execute push for web platform using JSDOM with real APIs
205+
*/
206+
async function executeWebPush(
207+
bundlePath: string,
208+
event: Record<string, unknown>,
209+
logger: Logger,
210+
): Promise<PushResult> {
211+
const startTime = Date.now();
212+
213+
try {
214+
// Create JSDOM with silent console
215+
const virtualConsole = new VirtualConsole();
216+
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
217+
url: 'http://localhost',
218+
runScripts: 'dangerously',
219+
resources: 'usable',
220+
virtualConsole,
221+
});
222+
223+
const { window } = dom;
224+
225+
// JSDOM provides fetch natively, no need to inject node-fetch
226+
227+
// Load and execute bundle
228+
logger.debug('Loading bundle...');
229+
const bundleCode = await fs.readFile(bundlePath, 'utf8');
230+
window.eval(bundleCode);
231+
232+
// Wait for window.elb assignment
233+
logger.debug('Waiting for elb...');
234+
await waitForWindowProperty(
235+
window as unknown as Record<string, unknown>,
236+
'elb',
237+
5000,
238+
);
239+
240+
const windowObj = window as unknown as Record<string, unknown>;
241+
const elb = windowObj.elb as unknown as (
242+
name: string,
243+
data: Record<string, unknown>,
244+
) => Promise<Elb.PushResult>;
245+
246+
// Push event
247+
logger.info(`Pushing event: ${event.name}`);
248+
const eventData = (event.data || {}) as Record<string, unknown>;
249+
const elbResult = await elb(event.name as string, eventData);
250+
251+
return {
252+
success: true,
253+
elbResult,
254+
duration: Date.now() - startTime,
255+
};
256+
} catch (error) {
257+
return {
258+
success: false,
259+
duration: Date.now() - startTime,
260+
error: getErrorMessage(error),
261+
};
262+
}
263+
}
264+
265+
/**
266+
* Execute push for server platform using Node.js
267+
*/
268+
async function executeServerPush(
269+
bundlePath: string,
270+
event: Record<string, unknown>,
271+
logger: Logger,
272+
timeout: number = 60000, // 60 second default timeout
273+
): Promise<PushResult> {
274+
const startTime = Date.now();
275+
276+
try {
277+
// Create timeout promise
278+
const timeoutPromise = new Promise<never>((_, reject) => {
279+
setTimeout(
280+
() => reject(new Error(`Server push timeout after ${timeout}ms`)),
281+
timeout,
282+
);
283+
});
284+
285+
// Execute with timeout
286+
const executePromise = (async () => {
287+
// Dynamic import of ESM bundle
288+
logger.debug('Importing bundle...');
289+
const flowModule = await import(bundlePath);
290+
291+
if (!flowModule.default || typeof flowModule.default !== 'function') {
292+
throw new Error('Bundle does not export default factory function');
293+
}
294+
295+
// Call factory function to start flow
296+
logger.debug('Calling factory function...');
297+
const result = await flowModule.default();
298+
299+
if (!result || !result.elb || typeof result.elb !== 'function') {
300+
throw new Error(
301+
'Factory function did not return valid result with elb',
302+
);
303+
}
304+
305+
const { elb } = result;
306+
307+
// Push event
308+
logger.info(`Pushing event: ${event.name}`);
309+
const eventData = (event.data || {}) as Record<string, unknown>;
310+
const elbResult = await (
311+
elb as (
312+
name: string,
313+
data: Record<string, unknown>,
314+
) => Promise<Elb.PushResult>
315+
)(event.name as string, eventData);
316+
317+
return {
318+
success: true,
319+
elbResult,
320+
duration: Date.now() - startTime,
321+
};
322+
})();
323+
324+
// Race between execution and timeout
325+
return await Promise.race([executePromise, timeoutPromise]);
326+
} catch (error) {
327+
return {
328+
success: false,
329+
duration: Date.now() - startTime,
330+
error: getErrorMessage(error),
331+
};
332+
}
333+
}
334+
335+
/**
336+
* Wait for window property to be assigned
337+
*/
338+
function waitForWindowProperty(
339+
window: Record<string, unknown>,
340+
prop: string,
341+
timeout: number = 5000,
342+
): Promise<void> {
343+
return new Promise((resolve, reject) => {
344+
const start = Date.now();
345+
346+
const check = () => {
347+
if (window[prop] !== undefined) {
348+
resolve();
349+
} else if (Date.now() - start > timeout) {
350+
reject(
351+
new Error(
352+
`Timeout waiting for window.${prop}. IIFE may have failed to execute.`,
353+
),
354+
);
355+
} else {
356+
setImmediate(check);
357+
}
358+
};
359+
360+
check();
361+
});
362+
}
363+
364+
// Export types
365+
export type { PushCommandOptions, PushResult };
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Elb } from '@walkeros/core';
2+
import type { GlobalOptions } from '../../types/index.js';
3+
4+
/**
5+
* Push command options
6+
*/
7+
export interface PushCommandOptions extends GlobalOptions {
8+
config: string;
9+
event: string;
10+
env?: string;
11+
json?: boolean;
12+
}
13+
14+
/**
15+
* Push execution result
16+
*/
17+
export interface PushResult {
18+
success: boolean;
19+
elbResult?: Elb.PushResult;
20+
duration: number;
21+
error?: string;
22+
}

0 commit comments

Comments
 (0)