diff --git a/client/custom.d.ts b/client/custom.d.ts index 729f9e03de..cc0982df7a 100644 --- a/client/custom.d.ts +++ b/client/custom.d.ts @@ -8,6 +8,8 @@ declare module '*.svg' { export default ReactComponent; } +declare module 'blob-util'; + // Extend window for Redux DevTools interface Window { __REDUX_DEVTOOLS_EXTENSION__?: () => any; diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.tsx similarity index 65% rename from client/modules/Preview/EmbedFrame.jsx rename to client/modules/Preview/EmbedFrame.tsx index 02c766bd47..c6b49c0723 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.tsx @@ -1,5 +1,4 @@ import blobUtil from 'blob-util'; -import PropTypes from 'prop-types'; import React, { useRef, useEffect, useMemo } from 'react'; import styled from 'styled-components'; import { jsPreprocess } from './jsPreprocess'; @@ -13,14 +12,20 @@ import { NOT_EXTERNAL_LINK_REGEX } from '../../../server/utils/fileUtils'; import { getAllScriptOffsets } from '../../utils/consoleUtils'; +import type { ScriptOffset } from '../../utils/consoleUtils'; import { registerFrame } from '../../utils/dispatcher'; import { createBlobUrl } from './filesReducer'; +import type { PreviewFile } from './filesReducer'; import resolvePathsForElementsWithAttribute from '../../../server/utils/resolveUtils'; -let objectUrls = {}; -let objectPaths = {}; +let objectUrls: Record = {}; +let objectPaths: Record = {}; -const Frame = styled.iframe` +interface FrameProps { + fullView?: boolean; +} + +const Frame = styled.iframe` min-height: 100%; min-width: 100%; position: absolute; @@ -32,15 +37,17 @@ const Frame = styled.iframe` `} `; -function resolveCSSLinksInString(content, files) { +function resolveCSSLinksInString(content: string, files: PreviewFile[]) { let newContent = content; - let cssFileStrings = content.match(STRING_REGEX); - cssFileStrings = cssFileStrings || []; + const cssFileStrings = content.match(STRING_REGEX) || []; cssFileStrings.forEach((cssFileString) => { if (cssFileString.match(MEDIA_FILE_QUOTED_REGEX)) { - const filePath = cssFileString.substr(1, cssFileString.length - 2); - const quoteCharacter = cssFileString.substr(0, 1); - const resolvedFile = resolvePathToFile(filePath, files); + const filePath = cssFileString.slice(1, -1); + const quoteCharacter = cssFileString[0]; + const resolvedFile = resolvePathToFile(filePath, files) as + | PreviewFile + | false + | undefined; if (resolvedFile) { if (resolvedFile.url) { newContent = newContent.replace( @@ -54,15 +61,17 @@ function resolveCSSLinksInString(content, files) { return newContent; } -function resolveJSLinksInString(content, files) { +function resolveJSLinksInString(content: string, files: PreviewFile[]) { let newContent = content; - let jsFileStrings = content.match(STRING_REGEX); - jsFileStrings = jsFileStrings || []; + const jsFileStrings = content.match(STRING_REGEX) || []; jsFileStrings.forEach((jsFileString) => { if (jsFileString.match(MEDIA_FILE_QUOTED_REGEX)) { - const filePath = jsFileString.substr(1, jsFileString.length - 2); - const quoteCharacter = jsFileString.substr(0, 1); - const resolvedFile = resolvePathToFile(filePath, files); + const filePath = jsFileString.slice(1, -1); + const quoteCharacter = jsFileString[0]; + const resolvedFile = resolvePathToFile(filePath, files) as + | PreviewFile + | false + | undefined; if (resolvedFile) { if (resolvedFile.url) { @@ -83,105 +92,106 @@ function resolveJSLinksInString(content, files) { return jsPreprocess(newContent); } -function resolveScripts(sketchDoc, files) { +function resolveScripts(sketchDoc: Document, files: PreviewFile[]) { const scriptsInHTML = sketchDoc.getElementsByTagName('script'); const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML); scriptsInHTMLArray.forEach((script) => { - if ( - script.getAttribute('src') && - script.getAttribute('src').match(NOT_EXTERNAL_LINK_REGEX) !== null - ) { - const resolvedFile = resolvePathToFile(script.getAttribute('src'), files); + const src = script.getAttribute('src'); + if (src && src.match(NOT_EXTERNAL_LINK_REGEX) !== null) { + const resolvedFile = resolvePathToFile(src, files) as + | PreviewFile + | false + | undefined; if (resolvedFile) { if (resolvedFile.url) { script.setAttribute('src', resolvedFile.url); } else { - // in the future, when using y.js, could remake the blob for only the file(s) - // that changed const blobUrl = createBlobUrl(resolvedFile); script.setAttribute('src', blobUrl); - const blobPath = blobUrl.split('/').pop(); - // objectUrls[blobUrl] = `${resolvedFile.filePath}${ - // resolvedFile.filePath.length > 0 ? '/' : '' - // }${resolvedFile.name}`; - objectUrls[blobUrl] = `${resolvedFile.filePath}/${resolvedFile.name}`; + const blobPath = blobUrl.split('/').pop() || ''; + objectUrls[blobUrl] = `${ + ((resolvedFile as unknown) as { filePath: string }).filePath + }/${resolvedFile.name}`; objectPaths[blobPath] = resolvedFile.name; - // script.setAttribute('data-tag', `${startTag}${resolvedFile.name}`); - // script.removeAttribute('src'); - // script.innerHTML = resolvedFile.content; // eslint-disable-line } } - } else if ( - !( - script.getAttribute('src') && - script.getAttribute('src').match(EXTERNAL_LINK_REGEX) - ) !== null - ) { + } else if (!(src && src.match(EXTERNAL_LINK_REGEX)) !== null) { script.setAttribute('crossorigin', ''); - script.innerHTML = resolveJSLinksInString(script.innerHTML, files); // eslint-disable-line + script.innerHTML = resolveJSLinksInString(script.innerHTML, files); } }); } -function resolveStyles(sketchDoc, files) { +function resolveStyles(sketchDoc: Document, files: PreviewFile[]) { const inlineCSSInHTML = sketchDoc.getElementsByTagName('style'); const inlineCSSInHTMLArray = Array.prototype.slice.call(inlineCSSInHTML); inlineCSSInHTMLArray.forEach((style) => { - style.innerHTML = resolveCSSLinksInString(style.innerHTML, files); // eslint-disable-line + style.innerHTML = resolveCSSLinksInString(style.innerHTML, files); }); const cssLinksInHTML = sketchDoc.querySelectorAll('link[rel="stylesheet"]'); const cssLinksInHTMLArray = Array.prototype.slice.call(cssLinksInHTML); cssLinksInHTMLArray.forEach((css) => { - if ( - css.getAttribute('href') && - css.getAttribute('href').match(NOT_EXTERNAL_LINK_REGEX) !== null - ) { - const resolvedFile = resolvePathToFile(css.getAttribute('href'), files); + const href = css.getAttribute('href'); + if (href && href.match(NOT_EXTERNAL_LINK_REGEX) !== null) { + const resolvedFile = resolvePathToFile(href, files) as + | PreviewFile + | false + | undefined; if (resolvedFile) { if (resolvedFile.url) { - css.href = resolvedFile.url; // eslint-disable-line + css.setAttribute('href', resolvedFile.url); } else { const style = sketchDoc.createElement('style'); - style.innerHTML = `\n${resolvedFile.content}`; + style.innerHTML = `\n${resolvedFile.content || ''}`; sketchDoc.head.appendChild(style); - css.parentElement.removeChild(css); + css.parentElement?.removeChild(css); } } } }); } -function resolveJSAndCSSLinks(files) { - const newFiles = []; +function resolveJSAndCSSLinks(files: PreviewFile[]) { + const newFiles: PreviewFile[] = []; files.forEach((file) => { const newFile = { ...file }; if (file.name.match(/.*\.js$/i)) { - newFile.content = resolveJSLinksInString(newFile.content, files); + newFile.content = resolveJSLinksInString(newFile.content || '', files); } else if (file.name.match(/.*\.css$/i)) { - newFile.content = resolveCSSLinksInString(newFile.content, files); + newFile.content = resolveCSSLinksInString(newFile.content || '', files); } newFiles.push(newFile); }); return newFiles; } -function addLoopProtect(sketchDoc) { +function addLoopProtect(sketchDoc: Document) { const scriptsInHTML = sketchDoc.getElementsByTagName('script'); const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML); scriptsInHTMLArray.forEach((script) => { - script.innerHTML = jsPreprocess(script.innerHTML); // eslint-disable-line + script.innerHTML = jsPreprocess(script.innerHTML); }); } -function injectLocalFiles(files, htmlFile, options) { +interface InjectLocalFilesOptions { + basePath: string; + gridOutput: boolean; + textOutput: boolean; +} + +function injectLocalFiles( + files: PreviewFile[], + htmlFile: PreviewFile, + options: InjectLocalFilesOptions +) { const { basePath, gridOutput, textOutput } = options; - let scriptOffs = []; + let scriptOffs: ScriptOffset[] = []; objectUrls = {}; objectPaths = {}; const resolvedFiles = resolveJSAndCSSLinks(files); const parser = new DOMParser(); - const sketchDoc = parser.parseFromString(htmlFile.content, 'text/html'); + const sketchDoc = parser.parseFromString(htmlFile.content || '', 'text/html'); const base = sketchDoc.createElement('base'); base.href = `${window.origin}${basePath}${basePath.length > 1 && '/'}`; @@ -235,16 +245,33 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` return `\n${sketchDoc.documentElement.outerHTML}`; } -function getHtmlFile(files) { - return files.filter((file) => file.name.match(/.*\.html$/i))[0]; +function getHtmlFile(files: PreviewFile[]) { + return files.find((file) => file.name.match(/.*\.html$/i)); +} + +interface EmbedFrameProps { + files: PreviewFile[]; + isPlaying: boolean; + basePath: string; + gridOutput: boolean; + textOutput: boolean; } -function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { - const iframe = useRef(); +function EmbedFrame({ + files, + isPlaying, + basePath, + gridOutput, + textOutput +}: EmbedFrameProps) { + const iframe = useRef(null); const htmlFile = useMemo(() => getHtmlFile(files), [files]); - const srcRef = useRef(); + const srcRef = useRef(null); useEffect(() => { + if (!iframe.current) { + return () => {}; + } const unsubscribe = registerFrame( iframe.current.contentWindow, window.origin @@ -256,27 +283,31 @@ function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { function renderSketch() { const doc = iframe.current; - if (isPlaying) { + if (isPlaying && doc && htmlFile) { const htmlDoc = injectLocalFiles(files, htmlFile, { basePath, gridOutput, textOutput }); - const generatedHtmlFile = { + const generatedHtmlFile: PreviewFile = { + id: 'generated', name: 'index.html', - content: htmlDoc + content: htmlDoc, + children: [], + fileType: 'file' }; const htmlUrl = createBlobUrl(generatedHtmlFile); const toRevoke = srcRef.current; srcRef.current = htmlUrl; - // BRO FOR SOME REASON YOU HAVE TO DO THIS TO GET IT TO WORK ON SAFARI setTimeout(() => { - doc.src = htmlUrl; + if (doc) { + doc.src = htmlUrl; + } if (toRevoke) { blobUtil.revokeObjectURL(toRevoke); } }, 0); - } else { + } else if (doc) { doc.src = ''; } } @@ -292,18 +323,4 @@ function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { ); } -EmbedFrame.propTypes = { - files: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - content: PropTypes.string.isRequired - }) - ).isRequired, - isPlaying: PropTypes.bool.isRequired, - basePath: PropTypes.string.isRequired, - gridOutput: PropTypes.bool.isRequired, - textOutput: PropTypes.bool.isRequired -}; - -export default EmbedFrame; +export { EmbedFrame }; diff --git a/client/modules/Preview/filesReducer.js b/client/modules/Preview/filesReducer.ts similarity index 50% rename from client/modules/Preview/filesReducer.js rename to client/modules/Preview/filesReducer.ts index 15534d6d7e..a0a1e92f1b 100644 --- a/client/modules/Preview/filesReducer.js +++ b/client/modules/Preview/filesReducer.ts @@ -1,48 +1,51 @@ -import { useMemo } from 'react'; import blobUtil from 'blob-util'; import mime from 'mime'; import { PLAINTEXT_FILE_REGEX } from '../../../server/utils/fileUtils'; -// https://gist.github.com/fnky/7d044b94070a35e552f3c139cdf80213 -export function useSelectors(state, mapStateToSelectors) { - const selectors = useMemo(() => mapStateToSelectors(state), [state]); - return selectors; +export interface PreviewFile { + id: string; + name: string; + content?: string; + blobUrl?: string; + url?: string; + children: string[]; + fileType: 'file' | 'folder'; } -export function getFileSelectors(state) { +export interface SetFilesAction { + type: 'SET_FILES'; + files: PreviewFile[]; +} + +export type FilesReducerAction = SetFilesAction; + +export function setFilesAction(files: PreviewFile[]) { return { - getHTMLFile: () => state.filter((file) => file.name.match(/.*\.html$/i))[0], - getJSFiles: () => state.filter((file) => file.name.match(/.*\.js$/i)), - getCSSFiles: () => state.filter((file) => file.name.match(/.*\.css$/i)) + type: 'SET_FILES' as const, + files }; } -function sortedChildrenId(state, children) { +function sortedChildrenId(state: PreviewFile[], children: string[]) { const childrenArray = state.filter((file) => children.includes(file.id)); childrenArray.sort((a, b) => (a.name > b.name ? 1 : -1)); return childrenArray.map((child) => child.id); } -export function setFiles(files) { - return { - type: 'SET_FILES', - files - }; -} - -export function createBlobUrl(file) { +export function createBlobUrl(file: PreviewFile) { if (file.blobUrl) { blobUtil.revokeObjectURL(file.blobUrl); } - const mimeType = mime.getType(file.name) || 'text/plain'; - - const fileBlob = blobUtil.createBlob([file.content], { type: mimeType }); + const mimeType = mime.lookup(file.name) || 'text/plain'; + const fileBlob = blobUtil.createBlob([file.content ?? ''], { + type: mimeType + }); const blobURL = blobUtil.createObjectURL(fileBlob); return blobURL; } -export function createBlobUrls(state) { +export function createBlobUrls(state: PreviewFile[]) { return state.map((file) => { if (file.name.match(PLAINTEXT_FILE_REGEX)) { const blobUrl = createBlobUrl(file); @@ -52,7 +55,7 @@ export function createBlobUrls(state) { }); } -export function filesReducer(state, action) { +export function filesReducer(state: PreviewFile[], action: FilesReducerAction) { switch (action.type) { case 'SET_FILES': return createBlobUrls(action.files); diff --git a/client/modules/Preview/jsPreprocess.js b/client/modules/Preview/jsPreprocess.js deleted file mode 100644 index 6acd46e6e8..0000000000 --- a/client/modules/Preview/jsPreprocess.js +++ /dev/null @@ -1,220 +0,0 @@ -import * as acorn from 'acorn'; -import * as walk from 'acorn-walk'; -import escodegen from 'escodegen'; - -const LOOP_TIMEOUT_MS = 100; - -function isShaderCall(node) { - const { callee } = node; - const isBuildShader = - callee.type === 'Identifier' && /^build\w*Shader$/.test(callee.name); - const isModifyCall = - callee.type === 'MemberExpression' && callee.property.name === 'modify'; - return isBuildShader || isModifyCall; -} - -function collectShaderFunctionNames(ast) { - const names = new Set(); - walk.simple(ast, { - CallExpression(node) { - if (isShaderCall(node)) { - node.arguments.forEach((arg) => { - if (arg.type === 'Identifier') { - names.add(arg.name); - } - }); - } - } - }); - return names; -} - -function makeVarDecl(varName) { - return { - type: 'VariableDeclaration', - kind: 'var', - declarations: [ - { - type: 'VariableDeclarator', - id: { type: 'Identifier', name: varName }, - init: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { type: 'Identifier', name: 'Date' }, - property: { type: 'Identifier', name: 'now' }, - computed: false - }, - arguments: [] - } - } - ] - }; -} - -function makeCheckStatement(varName, line) { - return { - type: 'IfStatement', - test: { - type: 'BinaryExpression', - operator: '>', - left: { - type: 'BinaryExpression', - operator: '-', - left: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { type: 'Identifier', name: 'Date' }, - property: { type: 'Identifier', name: 'now' }, - computed: false - }, - arguments: [] - }, - right: { type: 'Identifier', name: varName } - }, - right: { - type: 'Literal', - value: LOOP_TIMEOUT_MS, - raw: String(LOOP_TIMEOUT_MS) - } - }, - consequent: { - type: 'BlockStatement', - body: [ - { - type: 'ExpressionStatement', - expression: { - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { - type: 'MemberExpression', - object: { type: 'Identifier', name: 'window' }, - property: { type: 'Identifier', name: 'loopProtect' }, - computed: false - }, - property: { type: 'Identifier', name: 'hit' }, - computed: false - }, - arguments: [{ type: 'Literal', value: line, raw: String(line) }] - } - }, - { type: 'BreakStatement', label: null } - ] - }, - alternate: null - }; -} - -function collectLoopsToProtect(ast, shaderNames) { - const loops = []; - - function visitNode(node, ancestors) { - const isInsideShader = ancestors.some((ancestor, idx) => { - if ( - ancestor.type === 'FunctionDeclaration' && - shaderNames.has(ancestor.id?.name) - ) { - return true; - } - if ( - ancestor.type === 'FunctionExpression' || - ancestor.type === 'ArrowFunctionExpression' - ) { - const parent = ancestors[idx - 1]; - if ( - parent?.type === 'CallExpression' && - isShaderCall(parent) && - parent.arguments.includes(ancestor) - ) { - return true; - } - if ( - parent?.type === 'VariableDeclarator' && - shaderNames.has(parent.id?.name) - ) { - return true; - } - } - return false; - }); - - if (isInsideShader) return; - - let parentBlock = null; - for (let i = ancestors.length - 1; i >= 0; i--) { - const ancestor = ancestors[i]; - if ( - ancestor !== node && - (ancestor.type === 'BlockStatement' || ancestor.type === 'Program') - ) { - parentBlock = ancestor; - break; - } - } - - loops.push({ loop: node, parentBlock }); - } - - walk.ancestor(ast, { - ForStatement: visitNode, - WhileStatement: visitNode, - DoWhileStatement: visitNode - }); - - return loops; -} - -function injectProtection(loops) { - loops.forEach(({ loop, parentBlock }, idx) => { - const varName = `_LP${idx}`; - const { line } = loop.loc.start; - const check = makeCheckStatement(varName, line); - - if (loop.body.type === 'BlockStatement') { - loop.body.body.unshift(check); - } else { - loop.body = { type: 'BlockStatement', body: [check, loop.body] }; - } - - if (parentBlock) { - const varDecl = makeVarDecl(varName); - const nodeIdx = parentBlock.body.indexOf(loop); - if (nodeIdx !== -1) { - parentBlock.body.splice(nodeIdx, 0, varDecl); - } - } - }); -} - -function parseJs(jsText) { - const options = { ecmaVersion: 'latest', locations: true }; - try { - return acorn.parse(jsText, { ...options, sourceType: 'script' }); - } catch (e) { - try { - return acorn.parse(jsText, { ...options, sourceType: 'module' }); - } catch (e2) { - return null; - } - } -} - -export function jsPreprocess(jsText) { - if (/\/\/\s*noprotect/.test(jsText)) { - return jsText; - } - - const ast = parseJs(jsText); - if (!ast) return jsText; - - const shaderNames = collectShaderFunctionNames(ast); - const loops = collectLoopsToProtect(ast, shaderNames); - - if (loops.length === 0) return jsText; - - injectProtection(loops); - - return escodegen.generate(ast); -} diff --git a/client/modules/Preview/jsPreprocess.ts b/client/modules/Preview/jsPreprocess.ts new file mode 100644 index 0000000000..dd7dd068fd --- /dev/null +++ b/client/modules/Preview/jsPreprocess.ts @@ -0,0 +1,274 @@ +import * as acorn from 'acorn'; +import * as walk from 'acorn-walk'; +import type { SimpleVisitors, AncestorVisitors } from 'acorn-walk'; +import escodegen from 'escodegen'; + +const LOOP_TIMEOUT_MS = 100; + +interface LoopInfo { + loop: acorn.ForStatement | acorn.WhileStatement | acorn.DoWhileStatement; + parentBlock: acorn.BlockStatement | acorn.Program | null; +} + +function isShaderCall(node: acorn.CallExpression): boolean { + const { callee } = node; + if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') { + return false; + } + if (callee.type === 'Identifier') { + return /^build\w*Shader$/.test(callee.name); + } + return ( + callee.property.type === 'Identifier' && callee.property.name === 'modify' + ); +} + +function collectShaderFunctionNames(ast: acorn.Program): Set { + const names = new Set(); + const visitors: SimpleVisitors = { + CallExpression(node: acorn.CallExpression) { + if (isShaderCall(node)) { + node.arguments.forEach((arg) => { + if (arg.type === 'Identifier') { + names.add(arg.name); + } + }); + } + } + }; + walk.simple(ast, visitors); + return names; +} + +function makeVarDecl(varName: string): acorn.VariableDeclaration { + return { + type: 'VariableDeclaration', + kind: 'var', + declarations: [ + ({ + type: 'VariableDeclarator', + id: ({ + type: 'Identifier', + name: varName + } as unknown) as acorn.Identifier, + init: ({ + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: ({ + type: 'Identifier', + name: 'Date' + } as unknown) as acorn.Expression, + property: ({ + type: 'Identifier', + name: 'now' + } as unknown) as acorn.Identifier, + computed: false + }, + arguments: [] + } as unknown) as acorn.Expression + } as unknown) as acorn.VariableDeclarator + ], + start: 0, + end: 0 + }; +} + +function makeCheckStatement(varName: string, line: number): acorn.IfStatement { + return { + type: 'IfStatement', + test: ({ + type: 'BinaryExpression', + operator: '>', + left: { + type: 'BinaryExpression', + operator: '-', + left: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'Date' }, + property: { type: 'Identifier', name: 'now' }, + computed: false + }, + arguments: [] + }, + right: { type: 'Identifier', name: varName } + }, + right: { + type: 'Literal', + value: LOOP_TIMEOUT_MS, + raw: String(LOOP_TIMEOUT_MS) + } + } as unknown) as acorn.BinaryExpression, + consequent: ({ + type: 'BlockStatement', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'window' }, + property: { type: 'Identifier', name: 'loopProtect' }, + computed: false + }, + property: { type: 'Identifier', name: 'hit' }, + computed: false + }, + arguments: [{ type: 'Literal', value: line, raw: String(line) }] + } + }, + { type: 'BreakStatement' } + ] + } as unknown) as acorn.BlockStatement, + alternate: null, + start: 0, + end: 0 + }; +} + +function collectLoopsToProtect( + ast: acorn.Program, + shaderNames: Set +): LoopInfo[] { + const loops: LoopInfo[] = []; + + const visitors: AncestorVisitors = { + ForStatement( + node: acorn.ForStatement, + _state: undefined, + ancestors: acorn.Node[] + ) { + collectLoop(node, ancestors, shaderNames, loops); + }, + WhileStatement( + node: acorn.WhileStatement, + _state: undefined, + ancestors: acorn.Node[] + ) { + collectLoop(node, ancestors, shaderNames, loops); + }, + DoWhileStatement( + node: acorn.DoWhileStatement, + _state: undefined, + ancestors: acorn.Node[] + ) { + collectLoop(node, ancestors, shaderNames, loops); + } + }; + + walk.ancestor(ast, visitors); + return loops; +} + +function collectLoop( + node: acorn.ForStatement | acorn.WhileStatement | acorn.DoWhileStatement, + ancestors: acorn.Node[], + shaderNames: Set, + loops: LoopInfo[] +): void { + const isInsideShader = ancestors.some((ancestor, idx) => { + if (ancestor.type === 'FunctionDeclaration') { + const fn = ancestor as acorn.FunctionDeclaration; + if (fn.id && shaderNames.has(fn.id.name)) { + return true; + } + } + if ( + ancestor.type === 'FunctionExpression' || + ancestor.type === 'ArrowFunctionExpression' + ) { + const parent = ancestors[idx - 1]; + if (parent?.type === 'CallExpression') { + if (isShaderCall(parent as acorn.CallExpression)) { + return true; + } + } + if (parent?.type === 'VariableDeclarator') { + const varId = (parent as acorn.VariableDeclarator).id; + if (varId.type === 'Identifier' && shaderNames.has(varId.name)) { + return true; + } + } + } + return false; + }); + + if (isInsideShader) return; + + let parentBlock: acorn.BlockStatement | acorn.Program | null = null; + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]; + if ( + ancestor !== node && + (ancestor.type === 'BlockStatement' || ancestor.type === 'Program') + ) { + parentBlock = ancestor as acorn.BlockStatement | acorn.Program; + break; + } + } + + loops.push({ loop: node, parentBlock }); +} + +function injectProtection(loops: LoopInfo[]): void { + loops.forEach((info, idx) => { + const varName = `_LP${idx}`; + const line = info.loop.loc?.start.line ?? 0; + const check = makeCheckStatement(varName, line); + + if (info.loop.body.type === 'BlockStatement') { + info.loop.body.body.unshift(check); + } else { + info.loop.body = ({ + type: 'BlockStatement', + body: [check, info.loop.body] + } as unknown) as acorn.Statement; + } + + if (info.parentBlock) { + const varDecl = makeVarDecl(varName); + const nodeIdx = info.parentBlock.body.indexOf( + (info.loop as unknown) as acorn.Statement + ); + if (nodeIdx !== -1) { + info.parentBlock.body.splice(nodeIdx, 0, varDecl); + } + } + }); +} + +function parseJs(jsText: string): acorn.Program | null { + const options = { ecmaVersion: 'latest' as const, locations: true }; + try { + return acorn.parse(jsText, { ...options, sourceType: 'script' as const }); + } catch { + try { + return acorn.parse(jsText, { ...options, sourceType: 'module' as const }); + } catch { + return null; + } + } +} + +export function jsPreprocess(jsText: string): string { + if (/\/\/\s*noprotect/.test(jsText)) { + return jsText; + } + + const ast = parseJs(jsText); + if (!ast) return jsText; + + const shaderNames = collectShaderFunctionNames(ast); + const loops = collectLoopsToProtect(ast, shaderNames); + + if (loops.length === 0) return jsText; + + injectProtection(loops); + + return escodegen.generate(ast); +} diff --git a/client/modules/Preview/jsPreprocess.unit.test.js b/client/modules/Preview/jsPreprocess.unit.test.ts similarity index 100% rename from client/modules/Preview/jsPreprocess.unit.test.js rename to client/modules/Preview/jsPreprocess.unit.test.ts diff --git a/client/modules/Preview/previewIndex.jsx b/client/modules/Preview/previewIndex.tsx similarity index 66% rename from client/modules/Preview/previewIndex.jsx rename to client/modules/Preview/previewIndex.tsx index 12c14e3b79..c6f29b9a4c 100644 --- a/client/modules/Preview/previewIndex.jsx +++ b/client/modules/Preview/previewIndex.tsx @@ -7,10 +7,11 @@ import { MessageTypes, dispatchMessage } from '../../utils/dispatcher'; -import { filesReducer, setFiles } from './filesReducer'; -import EmbedFrame from './EmbedFrame'; +import type { Message } from '../../utils/dispatcher'; +import { filesReducer, setFilesAction } from './filesReducer'; +import type { PreviewFile } from './filesReducer'; +import { EmbedFrame } from './EmbedFrame'; import { getConfig } from '../../utils/getConfig'; -import { initialState } from '../IDE/reducers/files'; const GlobalStyle = createGlobalStyle` body { @@ -19,22 +20,30 @@ const GlobalStyle = createGlobalStyle` `; const App = () => { - const [state, dispatch] = useReducer(filesReducer, [], initialState); + const [state, dispatch] = useReducer(filesReducer, [] as PreviewFile[]); const [isPlaying, setIsPlaying] = useState(false); const [basePath, setBasePath] = useState(''); const [textOutput, setTextOutput] = useState(false); const [gridOutput, setGridOutput] = useState(false); registerFrame(window.parent, getConfig('EDITOR_URL')); - function handleMessageEvent(message) { + function handleMessageEvent(message: Message) { const { type, payload } = message; switch (type) { - case MessageTypes.SKETCH: - dispatch(setFiles(payload.files)); - setBasePath(payload.basePath); - setTextOutput(payload.textOutput); - setGridOutput(payload.gridOutput); + // eslint-disable-next-line max-len + case MessageTypes.SKETCH: { + const sketchPayload = payload as { + files: PreviewFile[]; + basePath: string; + textOutput: boolean; + gridOutput: boolean; + }; + dispatch(setFilesAction(sketchPayload.files)); + setBasePath(sketchPayload.basePath); + setTextOutput(sketchPayload.textOutput); + setGridOutput(sketchPayload.gridOutput); break; + } case MessageTypes.START: setIsPlaying(true); break; @@ -45,14 +54,14 @@ const App = () => { dispatchMessage({ type: MessageTypes.REGISTER }); break; case MessageTypes.EXECUTE: - dispatchMessage(payload); + dispatchMessage(payload as Parameters[0]); break; default: break; } } - function addCacheBustingToAssets(files) { + function addCacheBustingToAssets(files: PreviewFile[]) { const timestamp = new Date().getTime(); return files.map((file) => { if (file.url && !file.url.endsWith('obj') && !file.url.endsWith('stl')) { @@ -71,6 +80,7 @@ const App = () => { unsubscribe(); }; }); + return ( diff --git a/package-lock.json b/package-lock.json index 217b261f43..b72d94c773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,6 +162,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/blob-util": "^1.3.3", "@types/classnames": "^2.3.0", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", @@ -13356,6 +13357,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/blob-util": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz", + "integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -46015,6 +46023,12 @@ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", "dev": true }, + "@types/blob-util": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz", + "integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==", + "dev": true + }, "@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", diff --git a/package.json b/package.json index 5519bafec0..da342968f1 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/blob-util": "^1.3.3", "@types/classnames": "^2.3.0", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14",