Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ScriptLoader #2417

Open
wants to merge 18 commits into
base: dev/1.4
Choose a base branch
from
Open

Add ScriptLoader #2417

wants to merge 18 commits into from

Conversation

MrKou47
Copy link
Member

@MrKou47 MrKou47 commented Oct 31, 2024

What kind of change does this PR introduce? (Bug fix, feature, docs update, ...)

Feature:

  • Add ScriptLoader
  • Add AssetType.Script

What is the current behavior? (You can also link to an open issue here)

What is the new behavior (if this is a feature change)?

Does this PR introduce a breaking change? (What changes might users need to make in their application due to this PR?)

Other information:

Summary by CodeRabbit

  • New Features

    • Introduced a new Script asset type for better asset management.
    • Added a ScriptLoader class for loading script assets efficiently, supporting both "js" and "mjs" file types.
    • Enhanced module functionality by exporting the ScriptLoader and ESModule for broader use.
  • Bug Fixes

    • Improved error handling during script loading to ensure better user experience.
  • Tests

    • Added unit tests for the ScriptLoader functionality, verifying script loading from both URL and Blob sources.

Copy link

coderabbitai bot commented Oct 31, 2024

Walkthrough

The changes in this pull request introduce a new enumeration value, Script, to the AssetType enum in the AssetType.ts file. Additionally, a new class named ScriptLoader is added in the ScriptLoader.ts file, which extends the Loader class. This class is designed to handle the loading of script assets, specifically for files with the extensions "js" and "mjs". The ScriptLoader class includes a load method that manages the asynchronous loading of script modules. Furthermore, the index.ts file is updated to import and export the new ScriptLoader class, and unit tests for the ScriptLoader functionality are introduced in ScriptLoader.test.ts.

Changes

File Change Summary
packages/core/src/asset/AssetType.ts Added new enum value: Script = "Script" to AssetType.
packages/loader/src/ScriptLoader.ts Introduced ScriptLoader class extending Loader, with an interface ESModule and a load(item: LoadItem): AssetPromise<ESModule> method.
packages/loader/src/index.ts Added import and export for ScriptLoader.
tests/src/loader/ScriptLoader.test.ts Created unit tests for ScriptLoader, verifying loading from URL and Blob, including an interface ESModule.
packages/core/src/animation/Keyframe.ts Reformatted conditional type expressions for readability in Keyframe class without changing logic.
packages/core/src/animation/internal/animationCurveOwner/AnimationCurveOwner.ts Reformatted conditional expressions for readability in methods of AnimationCurveOwner class without changing logic.
rollup.config.js Added a newline at the end of the file.

Suggested reviewers

  • GuoLei1990

Poem

In the meadow where scripts play,
A new type hops in, hip-hip-hooray!
With ScriptLoader to fetch and find,
Our code now dances, oh so refined.
Through AssetType, we grow and gleam,
A rabbit's joy, a coder's dream! 🐇✨


Thank you for using CodeRabbit. We offer it for free to the OSS community and would appreciate your support in helping us grow. If you find it useful, would you consider giving us a shout-out on your favorite social media?

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@MrKou47 MrKou47 requested a review from GuoLei1990 October 31, 2024 07:55
@MrKou47
Copy link
Member Author

MrKou47 commented Oct 31, 2024

Consider whether or not to use dynamic-import-polyfill

Data on support for the es6-module-dynamic-import feature across the major browsers from caniuse.com

Copy link

codecov bot commented Oct 31, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 69.47%. Comparing base (f3400c8) to head (2a1e40d).
Report is 58 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2417      +/-   ##
==========================================
- Coverage   69.50%   69.47%   -0.04%     
==========================================
  Files         524      522       -2     
  Lines       27405    27365      -40     
  Branches     4100     4097       -3     
==========================================
- Hits        19049    19011      -38     
- Misses       6852     6853       +1     
+ Partials     1504     1501       -3     
Flag Coverage Δ
unittests 69.47% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Outside diff range and nitpick comments (3)
packages/loader/src/ScriptLoader.ts (1)

3-3: Document the purpose of the decorator's boolean parameter

The false parameter in the decorator needs documentation to explain its purpose for future maintainability.

Add a comment explaining the boolean parameter:

+ // The false parameter indicates... [please specify the purpose]
 @resourceLoader(AssetType.Script, ["js", "mjs"], false)
packages/core/src/asset/AssetType.ts (2)

60-61: Consider enhancing the documentation for Script type.

The current inline comment could be more descriptive about the supported module formats and usage.

Consider updating to match the documentation style of other entries:

-  /** Script in ES module */
+  /**
+   * ES module script.
+   * @remarks Supports .js and .mjs module files for dynamic loading.
+   */

59-59: Consider standardizing the casing of enum values.

The project value uses lowercase while all other enum values (including the new Script) use PascalCase. This inconsistency could be confusing for developers.

Consider updating to maintain consistency:

-  Project = "project",
+  Project = "Project",
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between 5d147ae and 2a1e40d.

📒 Files selected for processing (2)
  • packages/core/src/asset/AssetType.ts (1 hunks)
  • packages/loader/src/ScriptLoader.ts (1 hunks)
🔇 Additional comments (2)
packages/loader/src/ScriptLoader.ts (1)

8-8: Verify URL type safety

The direct casting of the import promise to Promise<Script> might hide type incompatibilities.

Let's verify the Script type definition and usage:

packages/core/src/asset/AssetType.ts (1)

59-61: LGTM! The new Script enum value is well-placed.

The addition of the Script type aligns with the PR's objective of supporting script loading functionality.

Comment on lines 1 to 3
import { AssetPromise, AssetType, Loader, LoadItem, resourceLoader, Script } from "@galacean/engine-core";

@resourceLoader(AssetType.Script, ["js", "mjs"], false)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Consider adding input validation for script URLs

The loader handles both .js and .mjs files, but there's no URL validation before dynamic importing. This could pose security risks if untrusted URLs are processed.

Consider adding URL validation before processing:

+ import { isValidUrl } from "@galacean/engine-core";
 import { AssetPromise, AssetType, Loader, LoadItem, resourceLoader, Script } from "@galacean/engine-core";

 @resourceLoader(AssetType.Script, ["js", "mjs"], false)

Committable suggestion was skipped due to low confidence.

Comment on lines 4 to 14
class ScriptLoader extends Loader<Script> {
load(item: LoadItem): AssetPromise<Script> {
return new AssetPromise((resolve, reject) => {
const { url } = item;
(import(url) as Promise<Script>)
.then((esModule) => {
resolve(esModule);
})
.catch(reject);
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance browser compatibility and error handling

The current implementation has several areas for improvement:

  1. No polyfill for dynamic import in older browsers
  2. Basic error handling without specific error types
  3. No cleanup mechanism for failed loads

Consider implementing these improvements:

 class ScriptLoader extends Loader<Script> {
+  private loadingScripts: Map<string, Promise<Script>> = new Map();
+
   load(item: LoadItem): AssetPromise<Script> {
     return new AssetPromise((resolve, reject) => {
       const { url } = item;
-      (import(url) as Promise<Script>)
-        .then((esModule) => {
-          resolve(esModule);
-        })
-        .catch(reject);
+      
+      // Reuse existing load promise if available
+      const existing = this.loadingScripts.get(url);
+      if (existing) {
+        return existing.then(resolve).catch(reject);
+      }
+
+      const loadPromise = (async () => {
+        try {
+          // Use polyfill for dynamic import
+          const moduleLoader = await import('dynamic-import-polyfill');
+          const esModule = await moduleLoader.load(url);
+          return esModule;
+        } catch (error) {
+          throw new Error(`Failed to load script ${url}: ${error.message}`);
+        } finally {
+          this.loadingScripts.delete(url);
+        }
+      })();
+
+      this.loadingScripts.set(url, loadPromise);
+      loadPromise.then(resolve).catch(reject);
     });
   }
+
+  // Cleanup method for resource management
+  destroy(): void {
+    this.loadingScripts.clear();
+    super.destroy?.();
+  }
 }

Committable suggestion was skipped due to low confidence.

@MrKou47 MrKou47 changed the base branch from main to dev/1.4 November 1, 2024 09:19
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Outside diff range and nitpick comments (3)
packages/loader/src/ScriptLoader.ts (1)

8-9: Add class documentation and clarify decorator usage

The class lacks documentation explaining its purpose and usage. Additionally, the purpose of the false parameter in the decorator should be documented.

+/**
+ * Loader implementation for JavaScript modules (ES modules).
+ * Supports both .js and .mjs file extensions.
+ */
 @resourceLoader(AssetType.Script, ["js", "mjs"], false)
 export class ScriptLoader extends Loader<ESModuleStructure>
tests/src/loader/ScriptLoader.test.ts (2)

7-10: Consider strengthening the interface type definition.

The interface could be more specific about the allowed values for the index signature. Currently, [key: string]: any is too permissive and could mask type errors.

Consider this improvement:

 interface ESModuleStructure {
   default?: Script;
-  [key: string]: any;
+  [key: string]: Script | (() => string) | string | undefined;
 }

19-52: Add test coverage for error scenarios.

The current tests only cover successful scenarios. Consider adding tests for:

  1. Invalid script content
  2. Network errors
  3. Script loading cancellation
  4. Invalid MIME types
  5. Circular dependencies

Would you like me to help generate these additional test cases?

🧰 Tools
🪛 eslint

[error] 52-52: Insert

(prettier/prettier)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between 2a1e40d and 0a214c7.

📒 Files selected for processing (3)
  • packages/loader/src/ScriptLoader.ts (1 hunks)
  • packages/loader/src/index.ts (1 hunks)
  • tests/src/loader/ScriptLoader.test.ts (1 hunks)
🧰 Additional context used
🪛 GitHub Check: lint
packages/loader/src/index.ts

[failure] 29-29:
Replace './ScriptLoader' with "./ScriptLoader";

🪛 eslint
packages/loader/src/index.ts

[error] 29-29: Replace './ScriptLoader' with "./ScriptLoader";

(prettier/prettier)

tests/src/loader/ScriptLoader.test.ts

[error] 52-52: Insert

(prettier/prettier)

🔇 Additional comments (2)
packages/loader/src/ScriptLoader.ts (1)

10-19: 🛠️ Refactor suggestion

Implement caching and improve error handling

The current implementation could benefit from:

  1. Caching mechanism to prevent redundant loads
  2. Detailed error messages for different failure scenarios
  3. Cleanup mechanism for failed loads
+private moduleCache = new Map<string, Promise<ESModuleStructure>>();
+
 load(item: LoadItem): AssetPromise<ESModuleStructure> {
   return new AssetPromise((resolve, reject) => {
     const { url } = item;
+    
+    // Return cached module if available
+    const cached = this.moduleCache.get(url);
+    if (cached) {
+      return cached.then(resolve).catch(reject);
+    }
+
+    const loadPromise = (async () => {
+      try {
         (import(url) as Promise<ESModuleStructure>)
-          .then((esModule) => {
-            resolve(esModule);
-          })
-          .catch(reject);
+          .then((esModule) => {
+            if (!esModule || (typeof esModule !== 'object')) {
+              throw new Error(`Invalid module format from ${url}`);
+            }
+            return esModule;
+          });
+      } catch (error) {
+        this.moduleCache.delete(url);
+        throw new Error(`Failed to load script from ${url}: ${error.message}`);
+      }
+    })();
+
+    this.moduleCache.set(url, loadPromise);
+    loadPromise.then(resolve).catch(reject);
   });
 }
+
+/**
+ * Cleans up cached modules
+ */
+destroy(): void {
+  this.moduleCache.clear();
+  super.destroy?.();
+}

Let's verify if there are any existing module caching implementations in the codebase:

packages/loader/src/index.ts (1)

22-22: LGTM!

The import statement follows the established pattern and is positioned appropriately with other loader imports.

Comment on lines 3 to 6
interface ESModuleStructure {
default?: Script;
[key: string]: any;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance type safety and documentation for ESModuleStructure interface

The interface could benefit from:

  1. Stricter typing instead of any
  2. JSDoc documentation explaining its purpose
  3. Validation for required properties
+/**
+ * Represents the structure of an ES module loaded by ScriptLoader.
+ * @property default - Optional default export containing the Script
+ * @property [key: string] - Additional named exports from the module
+ */
 interface ESModuleStructure {
   default?: Script;
-  [key: string]: any;
+  [key: string]: Script | unknown;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface ESModuleStructure {
default?: Script;
[key: string]: any;
}
/**
* Represents the structure of an ES module loaded by ScriptLoader.
* @property default - Optional default export containing the Script
* @property [key: string] - Additional named exports from the module
*/
interface ESModuleStructure {
default?: Script;
[key: string]: Script | unknown;
}

packages/loader/src/index.ts Outdated Show resolved Hide resolved
Comment on lines 12 to 17
before(async () => {
const canvasDOM = document.createElement("canvas");
canvasDOM.width = 1024;
canvasDOM.height = 1024;
engine = await WebGLEngine.create({ canvas: canvasDOM });
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add test cleanup to prevent memory leaks.

The test setup creates a WebGLEngine instance but doesn't clean it up after tests complete. Consider adding an after hook.

Add this cleanup code:

+after(() => {
+  engine.destroy();
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
before(async () => {
const canvasDOM = document.createElement("canvas");
canvasDOM.width = 1024;
canvasDOM.height = 1024;
engine = await WebGLEngine.create({ canvas: canvasDOM });
});
before(async () => {
const canvasDOM = document.createElement("canvas");
canvasDOM.width = 1024;
canvasDOM.height = 1024;
engine = await WebGLEngine.create({ canvas: canvasDOM });
});
after(() => {
engine.destroy();
});

Comment on lines 20 to 32
it("loader from string url", async () => {
engine.resourceManager.load<ESModuleStructure>({
url: "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.random).not.to.be.null;
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Improve test reliability and security.

Several concerns with the current implementation:

  1. Using an external CDN in tests makes them dependent on network connectivity and third-party availability
  2. Missing error handling for failed requests
  3. No timeout specified for the async operation

Consider these improvements:

 it("loader from string url", async () => {
+  const timeout = 5000; // 5 seconds
+  try {
     engine.resourceManager.load<ESModuleStructure>({
       url: "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
       type: AssetType.Script
     })
-    .then((script) => {
+    .then((script) => {
       expect(script).not.to.be.null;
       expect(script.default).not.to.be.null;
       expect(script.colord).not.to.be.null;
       expect(script.getFormat).not.to.be.null;
       expect(script.random).not.to.be.null;
-    });
+    }, { timeout });
+  } catch (error) {
+    throw new Error(`Script loading failed: ${error.message}`);
+  }
 });

Consider mocking the external dependency for more reliable tests.

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines 34 to 51
it("loader from blob raw script text", async () => {
const esModuleString = `
export const colord = "colord";
export const getFormat = () => "getFormat";
export default colord;
`
engine.resourceManager.load<ESModuleStructure>({
url: URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" })),
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.default).equal(script.colord)
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add resource cleanup and improve test assertions.

The test creates a Blob URL but doesn't clean it up, which could lead to memory leaks. Also, the assertions could be more specific.

Apply these improvements:

 it("loader from blob raw script text", async () => {
   const esModuleString = `
     export const colord = "colord";
     export const getFormat = () => "getFormat";
     export default colord;
   `
+  const blobUrl = URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" }));
+  try {
     engine.resourceManager.load<ESModuleStructure>({
-      url: URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" })),
+      url: blobUrl,
       type: AssetType.Script
     })
     .then((script) => {
       expect(script).not.to.be.null;
-      expect(script.colord).not.to.be.null;
-      expect(script.getFormat).not.to.be.null;
-      expect(script.default).not.to.be.null;
+      expect(script.colord).to.equal("colord");
+      expect(script.getFormat).to.be.a("function");
+      expect(script.getFormat()).to.equal("getFormat");
       expect(script.default).equal(script.colord)
     });
+  } finally {
+    URL.revokeObjectURL(blobUrl);
+  }
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("loader from blob raw script text", async () => {
const esModuleString = `
export const colord = "colord";
export const getFormat = () => "getFormat";
export default colord;
`
engine.resourceManager.load<ESModuleStructure>({
url: URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" })),
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.default).equal(script.colord)
});
});
it("loader from blob raw script text", async () => {
const esModuleString = `
export const colord = "colord";
export const getFormat = () => "getFormat";
export default colord;
`
const blobUrl = URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" }));
try {
engine.resourceManager.load<ESModuleStructure>({
url: blobUrl,
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.colord).to.equal("colord");
expect(script.getFormat).to.be.a("function");
expect(script.getFormat()).to.equal("getFormat");
expect(script.default).equal(script.colord)
});
} finally {
URL.revokeObjectURL(blobUrl);
}
});

Comment on lines 1 to 52
import { expect } from "chai";
import { WebGLEngine, Script } from '@galacean/engine'
import { AssetType } from "@galacean/engine-core";

let engine: WebGLEngine;

interface ESModuleStructure {
default?: Script;
[key: string]: any;
}

before(async () => {
const canvasDOM = document.createElement("canvas");
canvasDOM.width = 1024;
canvasDOM.height = 1024;
engine = await WebGLEngine.create({ canvas: canvasDOM });
});

describe("ScriptLoader test", function () {
it("loader from string url", async () => {
engine.resourceManager.load<ESModuleStructure>({
url: "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.random).not.to.be.null;
});
});

it("loader from blob raw script text", async () => {
const esModuleString = `
export const colord = "colord";
export const getFormat = () => "getFormat";
export default colord;
`
engine.resourceManager.load<ESModuleStructure>({
url: URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" })),
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.default).equal(script.colord)
});
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Based on the implementation review, I can now provide a final response:

Dynamic script loading requires additional security measures

The ScriptLoader uses the native ES module import() function to load scripts, which inherits the following security features:

  1. Scripts can only be loaded from valid URLs or Blob URLs
  2. Scripts are executed in strict mode by default
  3. Scripts inherit Content Security Policy (CSP) restrictions from the host page

However, the loader lacks:

  1. Script integrity validation (SRI)
  2. Origin/domain validation
  3. Documentation about security best practices

Recommendations:

  • Add support for Subresource Integrity (SRI) hash validation
  • Document security considerations in the API docs
  • Consider adding domain allowlist functionality
🔗 Analysis chain

Consider security implications of dynamic script loading.

The ScriptLoader allows loading and executing scripts from external sources, which could pose security risks if used with untrusted content. Consider:

  1. Adding content security policies
  2. Implementing script integrity validation
  3. Adding documentation about security best practices

Let's check if there are any security measures in the main implementation:


Let me search for the ScriptLoader implementation to better understand the security context:


Let me check the ScriptLoader implementation to understand how scripts are loaded:

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for security-related code in ScriptLoader implementation
rg -i "integrity|security|policy|trust" --type ts

Length of output: 96


Script:

#!/bin/bash
# Find and examine ScriptLoader implementation
fd ScriptLoader.ts

Length of output: 56


Script:

#!/bin/bash
# Look for script loading related code
rg -A 10 "AssetType.Script" --type ts

Length of output: 2275


Script:

#!/bin/bash
# Search for any validation or security checks in resource loading
rg -A 5 "load.*\(" --type ts

Length of output: 157332


Script:

#!/bin/bash
# Get the ScriptLoader implementation
cat packages/loader/src/ScriptLoader.ts

Length of output: 637

🧰 Tools
🪛 eslint

[error] 52-52: Insert

(prettier/prettier)

@GuoLei1990 GuoLei1990 changed the title Feat: Add ScriptLoader Add ScriptLoader Nov 1, 2024
@GuoLei1990 GuoLei1990 added enhancement New feature or request script labels Nov 1, 2024
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Outside diff range and nitpick comments (3)
tests/src/loader/ScriptLoader.test.ts (3)

8-11: Consider making the interface more strict.

The [key: string]: any index signature makes the interface too permissive. Consider explicitly listing all expected properties for better type safety.

 interface ESModuleStructure {
   default?: Script;
-  [key: string]: any;
+  colord: unknown;
+  getFormat: () => string;
+  random?: unknown;
 }

13-18: Improve test setup maintainability.

Consider these improvements:

  1. Extract canvas dimensions as named constants
  2. Clean up the canvas DOM element in afterAll
+const CANVAS_WIDTH = 1024;
+const CANVAS_HEIGHT = 1024;
+let canvasDOM: HTMLCanvasElement;

 beforeAll(async () => {
-  const canvasDOM = document.createElement("canvas");
-  canvasDOM.width = 1024;
-  canvasDOM.height = 1024;
+  canvasDOM = document.createElement("canvas");
+  canvasDOM.width = CANVAS_WIDTH;
+  canvasDOM.height = CANVAS_HEIGHT;
   engine = await WebGLEngine.create({ canvas: canvasDOM });
 });

55-59: Fix code style.

Add missing semicolon after the last statement to maintain consistency.

 afterAll(function () {
   engine.resourceManager.gc();
-  engine.destroy();
+  engine.destroy();
 });
🧰 Tools
🪛 eslint

[error] 59-59: Insert

(prettier/prettier)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between 2ef9c8e and 12357bb.

📒 Files selected for processing (1)
  • tests/src/loader/ScriptLoader.test.ts (1 hunks)
🧰 Additional context used
🪛 eslint
tests/src/loader/ScriptLoader.test.ts

[error] 59-59: Insert

(prettier/prettier)

Comment on lines 21 to 34
it("loader from string url", async () => {

engine.resourceManager.load<ESModuleStructure>({
url: "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.random).not.to.be.null;
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Improve async test handling.

The test uses async/await syntax but then switches to .then() chain. Consider using consistent async/await pattern and add proper error handling.

 it("loader from string url", async () => {
-    
-    engine.resourceManager.load<ESModuleStructure>({
+    const script = await engine.resourceManager.load<ESModuleStructure>({
       url: "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
       type: AssetType.Script
-    })
-    .then((script) => {
-      expect(script).not.to.be.null;
-      expect(script.default).not.to.be.null;
-      expect(script.colord).not.to.be.null;
-      expect(script.getFormat).not.to.be.null;
-      expect(script.random).not.to.be.null;
     });
+    expect(script).not.to.be.null;
+    expect(script.default).not.to.be.null;
+    expect(script.colord).not.to.be.null;
+    expect(script.getFormat).not.to.be.null;
+    expect(script.random).not.to.be.null;
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("loader from string url", async () => {
engine.resourceManager.load<ESModuleStructure>({
url: "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.random).not.to.be.null;
});
});
it("loader from string url", async () => {
const script = await engine.resourceManager.load<ESModuleStructure>({
url: "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
type: AssetType.Script
});
expect(script).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.random).not.to.be.null;
});

Comment on lines 36 to 53
it("loader from blob raw script text", async () => {
const esModuleString = `
export const colord = "colord";
export const getFormat = () => "getFormat";
export default colord;
`
engine.resourceManager.load<ESModuleStructure>({
url: URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" })),
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.default).equal(script.colord)
})
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Improve test structure and async handling.

Similar to the previous test, consider using consistent async/await pattern and structured assertions.

 it("loader from blob raw script text", async () => {
   const esModuleString = `
     export const colord = "colord";
     export const getFormat = () => "getFormat";
     export default colord;
   `
-    engine.resourceManager.load<ESModuleStructure>({
+    const blobUrl = URL.createObjectURL(
+      new Blob([esModuleString], { type: "application/javascript" })
+    );
+    try {
+      const script = await engine.resourceManager.load<ESModuleStructure>({
-      url: URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" })),
+      url: blobUrl,
       type: AssetType.Script
-    })
-    .then((script) => {
-      expect(script).not.to.be.null;
-      expect(script.colord).not.to.be.null;
-      expect(script.getFormat).not.to.be.null;
-      expect(script.default).not.to.be.null;
-      expect(script.default).equal(script.colord)
-    })
+      });
+      expect(script).not.to.be.null;
+      expect(script.colord).not.to.be.null;
+      expect(script.getFormat).not.to.be.null;
+      expect(script.default).not.to.be.null;
+      expect(script.default).equal(script.colord);
+    } finally {
+      URL.revokeObjectURL(blobUrl);
+    }
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("loader from blob raw script text", async () => {
const esModuleString = `
export const colord = "colord";
export const getFormat = () => "getFormat";
export default colord;
`
engine.resourceManager.load<ESModuleStructure>({
url: URL.createObjectURL(new Blob([esModuleString], { type: "application/javascript" })),
type: AssetType.Script
})
.then((script) => {
expect(script).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.default).equal(script.colord)
})
});
it("loader from blob raw script text", async () => {
const esModuleString = `
export const colord = "colord";
export const getFormat = () => "getFormat";
export default colord;
`
const blobUrl = URL.createObjectURL(
new Blob([esModuleString], { type: "application/javascript" })
);
try {
const script = await engine.resourceManager.load<ESModuleStructure>({
url: blobUrl,
type: AssetType.Script
});
expect(script).not.to.be.null;
expect(script.colord).not.to.be.null;
expect(script.getFormat).not.to.be.null;
expect(script.default).not.to.be.null;
expect(script.default).equal(script.colord);
} finally {
URL.revokeObjectURL(blobUrl);
}
});

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Outside diff range and nitpick comments (1)
packages/loader/src/ScriptLoader.ts (1)

13-13: Document the purpose of @vite-ignore.

The /* @vite-ignore */ comment should be documented to explain why it's necessary. This helps maintainers understand the implications of removing it.

-      (import(/* @vite-ignore */url) as Promise<ESModuleStructure>)
+      // @vite-ignore is required to prevent Vite from trying to statically analyze dynamic imports
+      (import(/* @vite-ignore */ url) as Promise<ESModuleStructure>)
🧰 Tools
🪛 GitHub Check: lint

[failure] 13-13:
Insert ·

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between 12357bb and f67960e.

📒 Files selected for processing (1)
  • packages/loader/src/ScriptLoader.ts (1 hunks)
🧰 Additional context used
🪛 GitHub Check: lint
packages/loader/src/ScriptLoader.ts

[failure] 13-13:
Insert ·

🔇 Additional comments (3)
packages/loader/src/ScriptLoader.ts (3)

1-1: LGTM! Imports are well-organized and complete.


3-6: Reuse existing type safety comment.


10-19: Consider implementing the suggested dynamic-import-polyfill.

As mentioned in the PR comments, implementing the dynamic-import-polyfill would improve browser compatibility. This is especially important for environments that don't support dynamic imports natively.

Let's verify the browser compatibility requirements:

Additionally, the existing review comment about enhancing browser compatibility and error handling is still valid and should be addressed.

🧰 Tools
🪛 GitHub Check: lint

[failure] 13-13:
Insert ·

Comment on lines 8 to 20
@resourceLoader(AssetType.Script, ["js", "mjs"], false)
export class ScriptLoader extends Loader<ESModuleStructure> {
load(item: LoadItem): AssetPromise<ESModuleStructure> {
return new AssetPromise((resolve, reject) => {
const { url } = item;
(import(/* @vite-ignore */url) as Promise<ESModuleStructure>)
.then((esModule) => {
resolve(esModule);
})
.catch(reject);
});
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider adding resource cleanup.

The loader should implement cleanup logic to handle cases where the component is destroyed while scripts are still loading. This prevents memory leaks and ensures proper resource management.

 @resourceLoader(AssetType.Script, ["js", "mjs"], false)
 export class ScriptLoader extends Loader<ESModuleStructure> {
+  private loadingScripts = new Set<string>();
+
   load(item: LoadItem): AssetPromise<ESModuleStructure> {
     return new AssetPromise((resolve, reject) => {
       const { url } = item;
+      this.loadingScripts.add(url);
       (import(/* @vite-ignore */ url) as Promise<ESModuleStructure>)
         .then((esModule) => {
+          this.loadingScripts.delete(url);
           resolve(esModule);
         })
-        .catch(reject);
+        .catch((error) => {
+          this.loadingScripts.delete(url);
+          reject(error);
+        });
     });
   }
+
+  destroy(): void {
+    this.loadingScripts.clear();
+    super.destroy?.();
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@resourceLoader(AssetType.Script, ["js", "mjs"], false)
export class ScriptLoader extends Loader<ESModuleStructure> {
load(item: LoadItem): AssetPromise<ESModuleStructure> {
return new AssetPromise((resolve, reject) => {
const { url } = item;
(import(/* @vite-ignore */url) as Promise<ESModuleStructure>)
.then((esModule) => {
resolve(esModule);
})
.catch(reject);
});
}
}
@resourceLoader(AssetType.Script, ["js", "mjs"], false)
export class ScriptLoader extends Loader<ESModuleStructure> {
private loadingScripts = new Set<string>();
load(item: LoadItem): AssetPromise<ESModuleStructure> {
return new AssetPromise((resolve, reject) => {
const { url } = item;
this.loadingScripts.add(url);
(import(/* @vite-ignore */url) as Promise<ESModuleStructure>)
.then((esModule) => {
this.loadingScripts.delete(url);
resolve(esModule);
})
.catch((error) => {
this.loadingScripts.delete(url);
reject(error);
});
});
}
destroy(): void {
this.loadingScripts.clear();
super.destroy?.();
}
}
🧰 Tools
🪛 GitHub Check: lint

[failure] 13-13:
Insert ·

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Outside diff range and nitpick comments (2)
packages/loader/src/ScriptLoader.ts (2)

13-13: Document the purpose of the vite-ignore comment.

The /* @vite-ignore */ comment should be documented to explain why it's necessary and what implications it has for production builds.

+      // Ignore Vite's static analysis for dynamic imports since URLs are determined at runtime
       (import(/* @vite-ignore */ url) as Promise<ESModule>)

10-19: Enhance error handling and add retry mechanism.

The current implementation could benefit from:

  1. More specific error handling with custom error types
  2. A retry mechanism for transient network failures
   load(item: LoadItem): AssetPromise<ESModule> {
+    const maxRetries = 3;
+    const retryDelay = 1000;
+
+    const loadWithRetry = async (retriesLeft: number): Promise<ESModule> => {
+      try {
+        return await import(/* @vite-ignore */ url) as ESModule;
+      } catch (error) {
+        if (retriesLeft > 0 && error instanceof TypeError) {  // Network errors are TypeError
+          await new Promise(resolve => setTimeout(resolve, retryDelay));
+          return loadWithRetry(retriesLeft - 1);
+        }
+        throw new Error(`Failed to load script ${url}: ${error.message}`);
+      }
+    };
+
     return new AssetPromise((resolve, reject) => {
       const { url } = item;
-      (import(/* @vite-ignore */ url) as Promise<ESModule>)
-        .then((esModule) => {
-          resolve(esModule);
-        })
-        .catch(reject);
+      loadWithRetry(maxRetries).then(resolve).catch(reject);
     });
   }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between 89b957b and fde6eca.

📒 Files selected for processing (4)
  • packages/loader/src/ScriptLoader.ts (1 hunks)
  • packages/loader/src/index.ts (1 hunks)
  • rollup.config.js (1 hunks)
  • tests/src/loader/ScriptLoader.test.ts (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • rollup.config.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/loader/src/index.ts
  • tests/src/loader/ScriptLoader.test.ts
🔇 Additional comments (2)
packages/loader/src/ScriptLoader.ts (2)

1-2: LGTM! Imports are well-structured.

The necessary dependencies are properly imported from @galacean/engine-core.


1-20: Verify browser compatibility requirements.

The PR mentions considering dynamic-import-polyfill, but it's not implemented. Let's verify browser support requirements.

packages/loader/src/ScriptLoader.ts Outdated Show resolved Hide resolved
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Outside diff range and nitpick comments (2)
tests/src/loader/ScriptLoader.test.ts (2)

9-14: Add error handling to engine setup.

The engine setup could fail if WebGL is not supported. Consider adding try-catch and proper error handling:

 beforeAll(async () => {
+  try {
     const canvasDOM = document.createElement("canvas");
     canvasDOM.width = 1024;
     canvasDOM.height = 1024;
     engine = await WebGLEngine.create({ canvas: canvasDOM });
+  } catch (error) {
+    console.error('Failed to initialize WebGL engine:', error);
+    throw error;
+  }
 });

1-58: Run code formatter to fix style issues.

Multiple formatting issues were detected by the linter, including:

  • Missing semicolons
  • Inconsistent indentation
  • Missing spaces

Consider running the project's code formatter or prettier to fix these issues.

🧰 Tools
🪛 eslint

[error] 18-19: Replace ⏎····engine.resourceManager with engine.resourceManager⏎······

(prettier/prettier)


[error] 20-20: Insert ··

(prettier/prettier)


[error] 21-21: Insert ··

(prettier/prettier)


[error] 22-22: Insert ··

(prettier/prettier)


[error] 23-23: Insert ··

(prettier/prettier)


[error] 24-24: Insert ··

(prettier/prettier)


[error] 25-25: Insert ··

(prettier/prettier)


[error] 26-26: Insert ··

(prettier/prettier)


[error] 27-27: Insert ··

(prettier/prettier)


[error] 28-28: Insert ··

(prettier/prettier)


[error] 29-29: Insert ··

(prettier/prettier)


[error] 39-39: Insert ;

(prettier/prettier)


[error] 40-40: Insert ⏎······

(prettier/prettier)


[error] 41-41: Insert ··

(prettier/prettier)


[error] 42-42: Insert ··

(prettier/prettier)


[error] 43-43: Insert ··

(prettier/prettier)


[error] 44-44: Insert ··

(prettier/prettier)


[error] 45-45: Insert ··

(prettier/prettier)


[error] 46-46: Insert ··

(prettier/prettier)


[error] 47-47: Insert ··

(prettier/prettier)


[error] 48-48: Insert ··

(prettier/prettier)


[error] 49-49: Insert ··

(prettier/prettier)


[error] 50-50: Replace ······expect(script.default).equal(script.colord) with ········expect(script.default).equal(script.colord);

(prettier/prettier)


[error] 51-51: Replace ····}) with ······});

(prettier/prettier)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL

📥 Commits

Reviewing files that changed from the base of the PR and between fde6eca and a443c04.

📒 Files selected for processing (2)
  • packages/loader/src/ScriptLoader.ts (1 hunks)
  • tests/src/loader/ScriptLoader.test.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/loader/src/ScriptLoader.ts
🧰 Additional context used
🪛 eslint
tests/src/loader/ScriptLoader.test.ts

[error] 18-19: Replace ⏎····engine.resourceManager with engine.resourceManager⏎······

(prettier/prettier)


[error] 20-20: Insert ··

(prettier/prettier)


[error] 21-21: Insert ··

(prettier/prettier)


[error] 22-22: Insert ··

(prettier/prettier)


[error] 23-23: Insert ··

(prettier/prettier)


[error] 24-24: Insert ··

(prettier/prettier)


[error] 25-25: Insert ··

(prettier/prettier)


[error] 26-26: Insert ··

(prettier/prettier)


[error] 27-27: Insert ··

(prettier/prettier)


[error] 28-28: Insert ··

(prettier/prettier)


[error] 29-29: Insert ··

(prettier/prettier)


[error] 39-39: Insert ;

(prettier/prettier)


[error] 40-40: Insert ⏎······

(prettier/prettier)


[error] 41-41: Insert ··

(prettier/prettier)


[error] 42-42: Insert ··

(prettier/prettier)


[error] 43-43: Insert ··

(prettier/prettier)


[error] 44-44: Insert ··

(prettier/prettier)


[error] 45-45: Insert ··

(prettier/prettier)


[error] 46-46: Insert ··

(prettier/prettier)


[error] 47-47: Insert ··

(prettier/prettier)


[error] 48-48: Insert ··

(prettier/prettier)


[error] 49-49: Insert ··

(prettier/prettier)


[error] 50-50: Replace ······expect(script.default).equal(script.colord) with ········expect(script.default).equal(script.colord);

(prettier/prettier)


[error] 51-51: Replace ····}) with ······});

(prettier/prettier)

tests/src/loader/ScriptLoader.test.ts Show resolved Hide resolved
tests/src/loader/ScriptLoader.test.ts Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request script
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants