[SOLVED] Bundlers + TypeScript + ESM Scripts

I am currently exploring what a complete external build pipeline looks like for fairly large-scale commercial PlayCanvas game projects. I recently made a post about combining TypeScript with ESM Scripts synced with playcanvas-sync. Cool!

Now I want to understand how I can create re-usable libraries for all my company’s games. I create each library as a separate Node.js package, keep them in a monorepo and publish them privately via GitHub Packages. I can then either add a dependency to the published library, or use npm link to symlink a local version if I also want to develop the library in parallel. All of this works now and I’m starting to understand how Node.js in particular works. Nice!

To make the libraries usable in a PlayCanvas-ready build, I use a bundler that resolves npm imports and emits ESM output with shared chunks for browser execution. This all looks good and seems to work.

However.

The bundler rewrites the module structure and emits the script class using an export list form (export { TestScript }) instead of a direct class export (export class TestScript extends Script).

PlayCanvas’ ESM script detection does not appear to register scripts unless the class is exported using the direct export class syntax.

If anyone tried anything similar it would be interesting to know if I’m doing it wrong, if you’ve run into similar problems. Maybe it’s a matter of an additional argument to my bundler to retain direct exports or just using a different bundler. I don’t think syntax-wise the output is wrong, but PlayCanvas doesn’t like it.

Original TS-file (the external library is not of interest in this example):

import {Script} from "playcanvas";
import {TestLibraryClass} from "@mikim-entertainment/test-library";

export class TestScript extends Script { // Note export syntax
  static scriptName = "testScript";
 
  initialize() { 
    const test = new TestLibraryClass();
  } 
} 

And the bundler output, which is semantically equivalent ESM, but isn’t picked up by PlayCanvas ESM script detection:

import { TestLibraryClass } from "./chunk-QQOFT3CH.mjs";

// src/pc/test-script.ts
import { Script } from "playcanvas";
var TestScript = class extends Script { // Note export syntax
  static scriptName = "testScript";
  initialize() {
    const test = new TestLibraryClass();
  }
};
export { // Note export syntax
  TestScript
};
//# sourceMappingURL=test-script.mjs.map

If I manually change the export syntax to this (below) it works, but I’d like to be able to use the bundler output without having to modify the files (obviously):

export class TestScript extends Script {

I’m using esbuild to bundle and pcwatch to sync to PlayCanvas. Is this a limitation of PlayCanvas’ current ESM script detection (possibly a bug or unimplemented case), or is there a bundler configuration or alternative bundler that preserves the export class form for entry modules?

I did just find another post with a very similar problem. Didn’t find it before when searching.

The answer might be “PlayCanvas simply only supports ‘vanilla’ ES Modules. Use a different bundler.”.

I did notice that this works in ES Script parsing:

class TestScript extends Script {
    ...
};
export {TestScript};

It’s just this that doesn’t work (and it is the default syntax output by esbuild):

var TestScript = class extends Script {
    ...
};
export {TestScript};

I’ve tried using Rollup as bundler too now. It did structure the exports in a compatible way, but had some other problems generating PlayCanvas friendly code. To be investigated.

1 Like

Made it work with Rollup instead of esbuild.

The problem with Rollup was much more easily fixed than the export syntax used with esbuild.

So, for anyone stumbling upon this in the future. I’d recommend using Rollup instead of esbuild, otherwise the ES Scripts might not export properly so that PlayCanvas can pick them up.

Cheers!

3 Likes

Thank you for sharing!

I use rolldown in my engine-only project. Since it isn’t an Editor project, it doesn’t use PC scripts, just classical JS classes is enough.

Also, you don’t need to publish a library in order to import it. It can simply be cloned locally, say like this:

folder
  - lib1
  - lib2
  - project

Then import in package.json:

"dependencies": {
    "lib1": "file:../lib1"
}

Node will be able to resolve it correctly. For example:

image

You would need to npm install them and build locally as well, obviously. But I found it easier, since I can develop them in parallel and don’t need to publish to npm every change I do.

While at it, have you tried the new vscode extension from the team?

Also, you could drop by to Discord, if you have some questions regarding the extension and just chat: Discord

3 Likes

Thank you for the reply!

I see how your setup works. And I like it!

However, my requirements are a bit different I think. I am building a setup for a semi-large team and I am trying to build a future proof structure. One of the hard requirements I put on the solution is that each game projects needs to live in its own repo, and thus the shared technology needs to be separated from them somehow.

I chose to go with having all my tech stack libraries in one repo and each game in its own.

We are several developers who all focus on different projects and have different access. Since we use separate repos I didn’t want a “file” link to the dependencies. In my setup that would require the devs to clone the libs repo in a specific location relative the game or use submodules which I don’t like.

I also liked the fact that I can use clear versioning in the dependencies to my libraries. Old games can retain dependency on an old version of a library even in future game patches.

But I really appreciate the suggestion, and it does make it a tad easier than dragging GitHub Packages into it. :smiley:

I haven’t personally tried the new vscode extension, but now that you mention it, I will! I am already on Discord and have actually recently even posted in the vscode-extension channel.

3 Likes