Setting the file. One moment.
Render Images · Wix App · wix/skills · Skills Docs
ContentsBack to the top of the page async function main
— line 221
This file
Number 1.197
Position 197 of 205
Type JavaScript
Size 9 KB
Lines 254 scripts/ render-images.cjs
JavaScript · 254 lines · 9 KB
16 * `document.createElement('canvas')`, `OffscreenCanvas` and `Image` backed by
17 * `@napi-rs/canvas`; that and `esbuild` are installed under /tmp/user-images when
18 * missing, so the project is never touched.
19 *
20 * Each run writes into its own new folder (`/tmp/user-images/renders-…`), so the report
21 * lists only this run's images and two runs never touch each other's files. The generator
22 * is the user's code: it runs in a child process that gets none of this process's
23 * environment variables (no tokens or credentials): a fixed PATH, the locale, and HOME
24 * pointed at /tmp/user-images. Whatever the generator prints goes to stderr; the report
25 * comes back on its own channel, so the printed JSON is always the report.
26 *
27 * Prints one JSON report; each `renders[].file` is ready to pass to `upload-media.cjs`.
28 * Exit codes: 0 every image rendered, 1 at least one failed, 2 the script could not run.
29 */
30
31 const fs = require ( 'fs' );
32 const path = require ( 'path' );
33 const { createRequire } = require ( 'module' );
34 const { execFileSync , spawnSync } = require ( 'child_process' );
35
36 const WORK_DIR = '/tmp/user-images' ;
37 const CHILD_FLAG = '--render-child' ;
38 // The child writes its report here; its stdout carries only what the generator prints.
39 const REPORT_FD = 3 ;
40 const CHILD_PATH = '/usr/local/bin:/usr/bin:/bin' ;
41 const RENDER_TIMEOUT_MS = 120_000 ;
42 const INSTALL_TIMEOUT_MS = 180_000 ;
43 const DEPENDENCIES = [ '@napi-rs/canvas' , 'esbuild' ];
44 const EXTENSIONS = { 'image/png' : '.png' , 'image/jpeg' : '.jpg' , 'image/webp' : '.webp' };
45 const USAGE =
46 "Usage: node <SKILL_ROOT>/scripts/render-images.cjs <file> <export> '<json args>' ['<json args>' ...]" ;
47
48 function parseArgs ( argv ) {
49 const [ file , exportName , ... argSets ] = argv;
50 if ( ! file || ! exportName || argSets. length === 0 ) {
51 throw new Error ( 'Need a file, an export name and at least one JSON args array.' );
52 }
53 const calls = argSets. map (( text ) => {
54 let value;
55 try {
56 value = JSON . parse (text);
57 } catch {
58 throw new Error ( `Args must be a JSON array, got: ${ text }` );
59 }
60 if ( ! Array. isArray (value)) throw new Error ( `Args must be a JSON array, got: ${ text }` );
61 return value;
62 });
63 return { file, exportName, calls };
64 }
65
66 function requireFrom ( directory , name ) {
67 return createRequire (path. join (directory, 'noop.js' ))(name);
68 }
69
70 function loadDependency ( name ) {
71 for ( const directory of [process. cwd (), WORK_DIR ]) {
72 try {
73 return requireFrom (directory, name);
74 } catch {
75 // Not installed there.
76 }
77 }
78 return undefined ;
79 }
80
81 function loadDependencies () {
82 const missing = DEPENDENCIES . filter (( name ) => ! loadDependency (name));
83 if (missing. length ) {
84 fs. mkdirSync ( WORK_DIR , { recursive: true });
85 execFileSync (
86 'npm' ,
87 [
88 'install' ,
89 '--prefix' ,
90 WORK_DIR ,
91 '--no-save' ,
92 '--no-audit' ,
93 '--no-fund' ,
94 '--loglevel=error' ,
95 ... missing,
96 ],
97 { stdio: 'ignore' , timeout: INSTALL_TIMEOUT_MS },
98 );
99 }
100 return { canvas: loadDependency ( '@napi-rs/canvas' ), esbuild: loadDependency ( 'esbuild' ) };
101 }
102
103 // The browser globals a generator touches, backed by @napi-rs/canvas.
104 function installBrowserGlobals ( napi ) {
105 const createElement = ( tag ) => {
106 if ( String (tag). toLowerCase () !== 'canvas' ) {
107 throw new Error ( `document.createElement('${ tag }') isn't available while rendering` );
108 }
109 return napi. createCanvas ( 300 , 150 );
110 };
111 globalThis.document = { createElement, hidden: false };
112 globalThis.window = globalThis;
113 globalThis.devicePixelRatio = 1 ;
114 globalThis.OffscreenCanvas = class {
115 constructor ( width , height ) {
116 return napi. createCanvas (width, height);
117 }
118 };
119 globalThis.Image = napi.Image;
120 }
121
122 function loadGenerator ( file , exportName , esbuild ) {
123 const bundle = esbuild. buildSync ({
124 entryPoints: [path. resolve (file)],
125 bundle: true ,
126 write: false ,
127 platform: 'node' ,
128 format: 'cjs' ,
129 logLevel: 'silent' ,
130 external: [ '@napi-rs/canvas' ],
131 });
132 const module = { exports: {} };
133 // eslint-disable-next-line no-new-func
134 new Function ( 'module' , 'exports' , 'require' , bundle.outputFiles[ 0 ].text)(
135 module ,
136 module . exports ,
137 createRequire (path. resolve (file)),
138 );
139 const generator = module . exports [exportName];
140 if ( typeof generator !== 'function' ) {
141 const found = Object. keys ( module . exports ). join ( ', ' ) || 'none' ;
142 throw new Error ( `${ file } exports no function "${ exportName }" (exports: ${ found })` );
143 }
144 return generator;
145 }
146
147 function toImageBuffer ( result ) {
148 if ( typeof result === 'string' ) {
149 const match = / ^ data:(image \/ [\w+.-] + );base64,( . * ) $ / s . exec (result);
150 if ( ! match) throw new Error ( 'The function returned a string that is not a data:image URL' );
151 const extension = EXTENSIONS [match[ 1 ]];
152 if ( ! extension) throw new Error ( `Unsupported image type ${ match [ 1 ] }` );
153 return { bytes: Buffer. from (match[ 2 ], 'base64' ), extension };
154 }
155 if (result && typeof result.toBuffer === 'function' ) {
156 return { bytes: result. toBuffer ( 'image/png' ), extension: '.png' };
157 }
158 throw new Error ( 'The function returned neither a data:image URL nor a canvas' );
159 }
160
161 async function renderAll ({ file , exportName , calls }, { generator , outDir }) {
162 // A new folder per run: earlier runs' images are never listed, and concurrent runs don't collide.
163 fs. mkdirSync ( WORK_DIR , { recursive: true });
164 const folder = outDir ?? fs. mkdtempSync (path. join ( WORK_DIR , 'renders-' ));
165 const base = `${ path . parse ( file ). name }-${ exportName }` . replace ( / [ ^ a-z0-9] + / gi , '-' );
166 const renders = [];
167 for ( const [ index , args ] of calls. entries ()) {
168 try {
169 const { bytes , extension } = toImageBuffer ( await generator ( ... args));
170 const output = path. join (folder, `${ base }-${ index + 1 }${ extension }` );
171 fs. writeFileSync (output, bytes);
172 renders. push ({ args, status: 'rendered' , file: output });
173 } catch (error) {
174 renders. push ({ args, status: 'failed' , error: error.message });
175 }
176 }
177 const failed = renders. filter (( render ) => render.status === 'failed' ). length ;
178 return {
179 summary: { rendered: renders. length - failed, failed },
180 folder,
181 renders,
182 exitCode: failed ? 1 : 0 ,
183 };
184 }
185
186 /** The only environment the generator's process gets: enough to run Node, nothing secret. */
187 function childEnv ( env ) {
188 return {
189 PATH: CHILD_PATH ,
190 HOME: WORK_DIR ,
191 TMPDIR: WORK_DIR ,
192 LANG: env. LANG ?? 'C.UTF-8' ,
193 };
194 }
195
196 // Runs in the child process: loads the user's generator and renders each call.
197 async function renderInChild ( argv ) {
198 let options;
199 let generator;
200 try {
201 options = parseArgs (argv);
202 const canvas = loadDependency ( '@napi-rs/canvas' );
203 const esbuild = loadDependency ( 'esbuild' );
204 if ( ! canvas || ! esbuild) throw new Error ( '@napi-rs/canvas and esbuild are not installed' );
205 installBrowserGlobals (canvas);
206 generator = loadGenerator (options.file, options.exportName, esbuild);
207 } catch (error) {
208 console. error ( `${ error . message } \n ${ USAGE }` );
209 process. exit ( 2 );
210 }
211 const { exitCode , ... report } = await renderAll (options, { generator });
212 const json = `${ JSON . stringify ( report , null , 2 ) } \n ` ;
213 try {
214 fs. writeSync ( REPORT_FD , json);
215 } catch {
216 process.stdout. write (json); // run by hand, without the report channel
217 }
218 process. exit (exitCode);
219 }
220
221 async function main () {
222 const argv = process.argv. slice ( 2 );
223 if (argv[ 0 ] === CHILD_FLAG ) return renderInChild (argv. slice ( 1 ));
224 try {
225 parseArgs (argv);
226 const { canvas , esbuild } = loadDependencies ();
227 if ( ! canvas || ! esbuild)
228 throw new Error ( 'Could not install @napi-rs/canvas and esbuild under /tmp/user-images' );
229 } catch (error) {
230 console. error ( `${ error . message } \n ${ USAGE }` );
231 process. exit ( 2 );
232 }
233 fs. mkdirSync ( WORK_DIR , { recursive: true });
234 const child = spawnSync (process.execPath, [__filename, CHILD_FLAG , ... argv], {
235 cwd: process. cwd (),
236 env: childEnv (process.env),
237 encoding: 'utf8' ,
238 stdio: [ 'ignore' , 'pipe' , 'pipe' , 'pipe' ],
239 timeout: RENDER_TIMEOUT_MS ,
240 });
241 if (child.error) {
242 console. error ( `Rendering did not finish: ${ child . error . message } \n ${ USAGE }` );
243 process. exit ( 2 );
244 }
245 // What the generator printed is diagnostics, never part of the report.
246 process.stderr. write (child.stdout ?? '' );
247 process.stderr. write (child.stderr ?? '' );
248 process.stdout. write (child.output[ REPORT_FD ] ?? '' );
249 process. exit (child.status ?? 2 );
250 }
251
252 module . exports = { parseArgs, toImageBuffer, renderAll, childEnv };
253
254 if (require.main === module ) main ();