Run Three js on Node JS?

Welcome to the life-long lesson of “Decouple Your Rendering From Game Logic Before It Becomes a Problem”.

But since you’d likely be more happy with a dirty-but-working solution than a polite yet firm suggestion to "go back and rewrite the thing from scratch, so that the server-side doesn’t ever need to use model or texture loaders¹ - you can save yourself by using jsdom. JSDOM is quite limited, and will not work right away with three.js, but you are free to append necessary polyfills so that loaders stop complaining (this code is taken from a 2022 project, so keep in mind part of it may not be needed anymore):

// NOTE This code was using jsdom@21.1.0, may or may not work with more recent versions
import jsdom from 'jsdom';
import fs from 'fs';
import { TextDecoder, TextEncoder } from 'util';

// NOTE This should fetch index.html of your bundled game
let serverIndex = fs.readFileSync('./build/index.html').toString();

// NOTE Resolve all scripts of your serverIndex file
// (just load the JS files and inline the scripts from correct paths.
//      This is generally easier and faster with JSDOM than setting up alternative resolving paths in express.js)
serverIndex = serverIndex.split('<script src="').map((fragment, index) => {
  if (index === 0) {
    return fragment;
  }

  const [ url ] = fragment.split('"');

  return [
    '<script>',
    fs.readFileSync(`./build${url}`).toString(),
    '</script>'
  ].join('');
}).join('');

// NOTE Polyfill APIs used by Loaders that are not polyfilled by JSDOM
const polyfills = `
  <script>
    window.DOMRect = window.DOMRect || function (x, y, width, height) { 
      this.x = this.left = x;
      this.y = this.top = y;
      this.width = width;
      this.height = height;
      this.bottom = y + height;
      this.right = x + width;
    };

    window.URL = {
      createObjectURL: (blob) => {
        return "data:" + blob.type + ";base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==";
      },
      revokeObjectURL: () => {},
    };

    window.document.createElementNS = (() => {
      const originalCreateFn = window.document.createElementNS.bind(window.document);
  
      return (spec, type) => {
        if (type === 'img') {
          const mockImage = originalCreateFn(spec, type);
  
          setTimeout(() => {
            mockImage.dispatchEvent(new window.Event('load'));
          }, 1);
  
          return mockImage;
        } else {
          return originalCreateFn(spec, type);
        }
      };
    })();
    window.AudioBuffer = class AudioBuffer {};

    // NOTE Needed only if you use troika-three-text
    window.TroikaText = class TroikaText {};

    window.console.log = () => {};
    window.console.info = () => {};
    window.console.warn = () => {};
    window.console.error = () => {};
  </script>
`;

// NOTE Combine polyfills and the index file into a single "executable" JSDOM page
serverIndex = `${polyfills}${serverIndex}`;

const initServerWorld = () => {
  console.info('initServerWorld', 'start');

  const resourceLoader = new jsdom.ResourceLoader({
    userAgent: 'my-game-agent',
  });

  const emulation = new jsdom.JSDOM(serverIndex, {
    url: 'http://localhost:2567',
    runScripts: 'dangerously',
    resources: resourceLoader,
    pretendToBeVisual: true
  });

  class RequestMock {
    url;

    constructor(url) {
      this.url = `./build${url}`;
    }
  }

  class ResponseMock {
    _body;
    status = 200;
    statusText = 'ok';

    constructor(body, options) {
      this._body = body;
    }
    
    arrayBuffer() {
      const buffer = new emulation.window.ArrayBuffer(this._body.length);
      const view = new emulation.window.Uint8Array(buffer);

      for (let i = 0, total = this._body.length; i < total; i++) {
        view[i] = this._body.readUInt8(i);
      }

      return buffer;
    }

    text() {
      return this._body.toString();
    }

    json() {
      return JSON.parse(this._body.toString());
    }
  }

  const fetchMock = (request) => {
    return new Promise(resolve => {
      resolve(new ResponseMock(fs.readFileSync(request.url)));
    });
  }

  console.info('initServerWorld', 'mock emulation');

  // NOTE Server-side version of the same polyfills (may not be necessary in the newer JSDOM versions)
  emulation.window.Request = RequestMock;
  emulation.window.Response = ResponseMock;
  emulation.window.fetch = fetchMock;
  emulation.window.URL.createObjectURL = (blob) => {
    return `data:${blob.type};base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==`;
  };
  emulation.window.URL.revokeObjectURL = (url) => {};
  emulation.window.document.createElementNS = (() => {
    const originalCreateFn = emulation.window.document.createElementNS.bind(emulation.window.document);

    return (spec, type) => {
      if (type === 'img') {
        const mockImage = originalCreateFn(spec, type);

        setTimeout(() => {
          mockImage.dispatchEvent(new emulation.window.Event('load'));
        }, 1);

        return mockImage;
      } else {
        return originalCreateFn(spec, type);
      }
    };
  })();
  emulation.window.AudioBuffer = class AudioBuffer {};
  emulation.window.TroikaText = class TroikaText {};
  emulation.window.TextDecoder = TextDecoder;
  emulation.window.TextEncoder = TextEncoder;

  console.info('initServerWorld', 'done');

  return emulation.window;
};

// NOTE Create server-side instance of the game, this is an instance of Window, you can append global-scope methods and helpers to it that'd let you communicate with the JSDOM instance
world = initServerWorld();
world.sendToServer = (action, payload) => { /* ... */ };

¹ In terms of how - each entity should have colliders defined separately from the model itself, you can see how Unreal does it, also back in the day if you tried running a World Of Warcraft private server locally, the “baking navmaps & collisions” part of the initialization is the part the decoupled frontend from the backend.

1 Like