Skip to content

Commit

Permalink
require: Add partial implementation of module search algorithm
Browse files Browse the repository at this point in the history
Add a partial implementation of the Node.js module search algorithm
described here:

https://nodejs.org/api/modules.html#modules_all_together

The functions LOAD_AS_FILE, LOAD_INDEX, LOAD_AS_DIRECTORY,
LOAD_NODE_MODULES and NODE_MODULES_PATHS outlined in the pseudocode
linked above are implemented, whereas the functionality outlined in
LOAD_SELF_REFERENCE and LOAD_PACKAGE_EXPORTS is missing.

The module resolution algorithm is implemented via a
new (*RequireModule).resolve(path string) method which returns the
resolved path to the file that can be loaded using the Registry's
SourceLoader.  The returned resolved path is always cleaned via
filepathClean.  The resolve method uses the Registry's SourceLoader
via the new (*Registry).getSource(path string) method to search for
modules and supports resolving both native ("core") and JavaScript
modules.

Add new resolveStart string field to RequireModule.  resolveStart is
used to store the start path for the module search algorithm in the
resolve method.  When require() is called outside of any other
require() calls, resolveStart should be set to the directory of the
currently executing script.  As this information is stored in
r.runtime.Program.src.name but not currently exported, resolveStart is
currently set to ".".  During nested require() calls, resolveStart is
set to the directory of the module being loaded.

Add new constructor NewRegistry which takes a variadic argument of
Option's.  Currently, valid options are WithLoader and
WithGlobalFolders.

Add new globalFolders string slice to Registry.  globalFolders stores
additional folders that are searched by require() and is used in
NODE_MODULES_PATHS in the pseudocode linked above.  By default, a
Registry has an empty globalFolders slice, but this can be changed
using WithGlobalFolders.

Add support for loading modules with a .json extension by passing the
contents of the file through JavaScript's JSON.parse and assigning the
return value to module.exports within the module wrapper function.

Add new test TestResolve to test module search algorithm.

Fixes #5.
  • Loading branch information
Niels Widger committed Jul 16, 2020
1 parent b2775b8 commit 9eebb8c
Show file tree
Hide file tree
Showing 3 changed files with 348 additions and 22 deletions.
91 changes: 73 additions & 18 deletions require/module.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package require

import (
"text/template"

js "github.com/dop251/goja"

"errors"
Expand All @@ -26,18 +28,49 @@ type Registry struct {
native map[string]ModuleLoader
compiled map[string]*js.Program

srcLoader SourceLoader
srcLoader SourceLoader
globalFolders []string
}

type RequireModule struct {
r *Registry
runtime *js.Runtime
modules map[string]*js.Object
r *Registry
runtime *js.Runtime
modules map[string]*js.Object
resolveStart string
}

func NewRegistry(opts ...Option) *Registry {
r := &Registry{}

for _, opt := range opts {
opt(r)
}

return r
}

func NewRegistryWithLoader(srcLoader SourceLoader) *Registry {
return &Registry{
srcLoader: srcLoader,
return NewRegistry(WithLoader(srcLoader))
}

type Option func(*Registry)

func WithLoader(srcLoader SourceLoader) Option {
return func(r *Registry) {
r.srcLoader = srcLoader
}
}

// WithGlobalFolders appends the given paths to the registry's list of
// global folders to search if the requested module is not found
// elsewhere. By default, a registry's global folders list is empty.
// In the reference Node.js implementation, the default global folders
// list is $NODE_PATH, $HOME/.node_modules, $HOME/.node_libraries and
// $PREFIX/lib/node, see
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders.
func WithGlobalFolders(globalFolders ...string) Option {
return func(r *Registry) {
r.globalFolders = globalFolders
}
}

Expand All @@ -64,17 +97,27 @@ func (r *Registry) RegisterNativeModule(name string, loader ModuleLoader) {
r.native[name] = loader
}

func (r *Registry) getSource(p string) ([]byte, error) {
srcLoader := r.srcLoader
if srcLoader == nil {
srcLoader = ioutil.ReadFile
}
return srcLoader(p)
}

func (r *Registry) getCompiledSource(p string) (prg *js.Program, err error) {
r.Lock()
defer r.Unlock()

prg = r.compiled[p]
if prg == nil {
srcLoader := r.srcLoader
if srcLoader == nil {
srcLoader = ioutil.ReadFile
}
if s, err1 := srcLoader(p); err1 == nil {
if buf, err1 := r.getSource(p); err1 == nil {
s := string(buf)

if filepath.Ext(p) == ".json" {
s = "module.exports = JSON.parse('" + template.JSEscapeString(s) + "')"
}

source := "(function(module, exports) {" + string(s) + "\n})"
prg, err = js.Compile(p, source, false)
if err == nil {
Expand Down Expand Up @@ -115,6 +158,10 @@ func (r *RequireModule) loadModule(path string, jsModule *js.Object) error {
if call, ok := js.AssertFunction(f); ok {
jsExports := jsModule.Get("exports")

origResolveStart := r.resolveStart
r.resolveStart = filepath.Dir(path)
defer func() { r.resolveStart = origResolveStart }()

// Run the module source, with "jsModule" as the "module" variable, "jsExports" as "this"(Nodejs capable).
_, err = call(jsExports, jsModule, jsExports)
if err != nil {
Expand All @@ -141,19 +188,27 @@ func filepathClean(p string) string {

// Require can be used to import modules from Go source (similar to JS require() function).
func (r *RequireModule) Require(p string) (ret js.Value, err error) {
p = filepathClean(p)
if p == "" {
err = IllegalModuleNameError
// TODO: if require() called from global context, set resolve
// start path to filepath.Dir(r.runtime.Program.src.name) (not
// currently exposed).
if r.resolveStart == "" {
r.resolveStart = "."
defer func() { r.resolveStart = "" }()
}

path, err := r.resolve(p)
if err != nil {
err = fmt.Errorf("Could not find module '%s': %v", p, err)
return
}
module := r.modules[p]
module := r.modules[path]
if module == nil {
module = r.runtime.NewObject()
module.Set("exports", r.runtime.NewObject())
r.modules[p] = module
err = r.loadModule(p, module)
r.modules[path] = module
err = r.loadModule(path, module)
if err != nil {
delete(r.modules, p)
delete(r.modules, path)
err = fmt.Errorf("Could not load module '%s': %v", p, err)
return
}
Expand Down
113 changes: 109 additions & 4 deletions require/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"testing"

js "github.com/dop251/goja"
Expand Down Expand Up @@ -128,12 +129,12 @@ func TestSourceLoader(t *testing.T) {

vm := js.New()

registry := NewRegistryWithLoader(func(name string) ([]byte, error) {
registry := NewRegistry(WithGlobalFolders("."), WithLoader(func(name string) ([]byte, error) {
if name == "m.js" {
return []byte(MODULE), nil
}
return nil, errors.New("Module does not exist")
})
}))
registry.Enable(vm)

v, err := vm.RunString(SCRIPT)
Expand Down Expand Up @@ -166,12 +167,12 @@ func TestStrictModule(t *testing.T) {

vm := js.New()

registry := NewRegistryWithLoader(func(name string) ([]byte, error) {
registry := NewRegistry(WithGlobalFolders("."), WithLoader(func(name string) ([]byte, error) {
if name == "m.js" {
return []byte(MODULE), nil
}
return nil, errors.New("Module does not exist")
})
}))
registry.Enable(vm)

v, err := vm.RunString(SCRIPT)
Expand All @@ -183,3 +184,107 @@ func TestStrictModule(t *testing.T) {
t.Fatalf("Unexpected result: %v", v)
}
}

func TestResolve(t *testing.T) {
mapFileSystemSourceLoader := func(files map[string]string) SourceLoader {
return func(path string) ([]byte, error) {
slashPath := filepath.ToSlash(path)
t.Logf("SourceLoader(%s) [%s]", path, slashPath)
s, ok := files[filepath.ToSlash(slashPath)]
if !ok {
return nil, InvalidModuleError
}
return []byte(s), nil
}
}

testRequire := func(src, path string, globalFolders []string, fs map[string]string) (*js.Runtime, js.Value, error) {
vm := js.New()
r := NewRegistry(WithGlobalFolders(globalFolders...), WithLoader(mapFileSystemSourceLoader(fs)))
rr := r.Enable(vm)
rr.resolveStart = src
t.Logf("Require(%s)", path)
ret, err := rr.Require(path)
if err != nil {
return nil, nil, err
}
return vm, ret, nil
}

globalFolders := []string{
"/usr/lib/node_modules",
"/home/src/.node_modules",
}

fs := map[string]string{
"/home/src/app/app.js": `exports.name = "app"`,
"/home/src/app2/app2.json": `{"name": "app2"}`,
"/home/src/app3/index.js": `exports.name = "app3"`,
"/home/src/app4/index.json": `{"name": "app4"}`,
"/home/src/app5/package.json": `{"main": "app5.js"}`,
"/home/src/app5/app5.js": `exports.name = "app5"`,
"/home/src/app6/package.json": `{"main": "."}`,
"/home/src/app6/index.js": `exports.name = "app6"`,
"/home/src/app7/package.json": `{"main": "bad.js"}`,
"/home/src/app7/index.js": `exports.name = "app7"`,
"/home/src/app8/package.json": `{"main": "./a/b/c/file.js"}`,
"/home/src/app8/a/b/c/file.js": `exports.name = "app8"`,
"/usr/lib/node_modules/app9": `exports.name = "app9"`,
"/home/src/app10/app10.js": `exports.name = require('./a/file.js').name`,
"/home/src/app10/a/file.js": `exports.name = require('./b/file.js').name`,
"/home/src/app10/a/b/file.js": `exports.name = require('./c/file.js').name`,
"/home/src/app10/a/b/c/file.js": `exports.name = "app10"`,
"/home/src/.node_modules/app11": `exports.name = "app11"`,
"/home/src/app12/a/b/c/app12.js": `exports.name = require('d/file.js').name`,
"/home/src/app12/node_modules/d/file.js": `exports.name = "app12"`,
}

for i, tc := range []struct {
src string
path string
ok bool
field string
value string
}{
// loadAsFile
{"/home/src", "./app/app", true, "name", "app"},
{"/home/src", "./app/app.js", true, "name", "app"},
{"/home/src", "./app/bad.js", false, "", ""},
{"/home/src", "./app2/app2", true, "name", "app2"},
{"/home/src", "./app2/app2.json", true, "name", "app2"},
{"/home/src", "./app/bad.json", false, "", ""},
// loadAsIndex
{"/home/src", "./app3", true, "name", "app3"},
{"/home/src", "./appx", false, "", ""},
{"/home/src", "./app4", true, "name", "app4"},
{"/home/src", "./appx", false, "", ""},
// loadAsDirectory
{"/home/src", "./app5", true, "name", "app5"},
{"/home/src", "./app6", true, "name", "app6"},
{"/home/src", "./app7", true, "name", "app7"},
{"/home/src", "./app8", true, "name", "app8"},
// loadNodeModules
{"/home/src", "app9", true, "name", "app9"},
{"/home/src", "app11", true, "name", "app11"},
{"/home/src", "./app12/a/b/c/app12.js", true, "name", "app12"},
// nested require()
{"/home/src", "./app10/app10", true, "name", "app10"},
} {
vm, mod, err := testRequire(tc.src, tc.path, globalFolders, fs)
if err != nil {
if tc.ok {
t.Errorf("%v: require() failed: %v", i, err)
}
continue
}
f := mod.ToObject(vm).Get(tc.field)
if f == nil {
t.Errorf("%v: field %q not found", i, tc.field)
continue
}
value := f.String()
if value != tc.value {
t.Errorf("%v: got %q expected %q", i, value, tc.value)
}
}
}
Loading

0 comments on commit 9eebb8c

Please sign in to comment.