Adding upstream version 0.28.1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
88f1d47ab6
commit
e28c88ef14
933 changed files with 194711 additions and 0 deletions
1141
plugins/jsvm/binds.go
Normal file
1141
plugins/jsvm/binds.go
Normal file
File diff suppressed because it is too large
Load diff
1745
plugins/jsvm/binds_test.go
Normal file
1745
plugins/jsvm/binds_test.go
Normal file
File diff suppressed because it is too large
Load diff
149
plugins/jsvm/form_data.go
Normal file
149
plugins/jsvm/form_data.go
Normal file
|
@ -0,0 +1,149 @@
|
|||
package jsvm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// FormData represents an interface similar to the browser's [FormData].
|
||||
//
|
||||
// The value of each FormData entry must be a string or [*filesystem.File] instance.
|
||||
//
|
||||
// It is intended to be used together with the JSVM `$http.send` when
|
||||
// sending multipart/form-data requests.
|
||||
//
|
||||
// [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData.
|
||||
type FormData map[string][]any
|
||||
|
||||
// Append appends a new value onto an existing key inside the current FormData,
|
||||
// or adds the key if it does not already exist.
|
||||
func (data FormData) Append(key string, value any) {
|
||||
data[key] = append(data[key], value)
|
||||
}
|
||||
|
||||
// Set sets a new value for an existing key inside the current FormData,
|
||||
// or adds the key/value if it does not already exist.
|
||||
func (data FormData) Set(key string, value any) {
|
||||
data[key] = []any{value}
|
||||
}
|
||||
|
||||
// Delete deletes a key and its value(s) from the current FormData.
|
||||
func (data FormData) Delete(key string) {
|
||||
delete(data, key)
|
||||
}
|
||||
|
||||
// Get returns the first value associated with a given key from
|
||||
// within the current FormData.
|
||||
//
|
||||
// If you expect multiple values and want all of them,
|
||||
// use the [FormData.GetAll] method instead.
|
||||
func (data FormData) Get(key string) any {
|
||||
values, ok := data[key]
|
||||
if !ok || len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// GetAll returns all the values associated with a given key
|
||||
// from within the current FormData.
|
||||
func (data FormData) GetAll(key string) []any {
|
||||
values, ok := data[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// Has returns whether a FormData object contains a certain key.
|
||||
func (data FormData) Has(key string) bool {
|
||||
values, ok := data[key]
|
||||
|
||||
return ok && len(values) > 0
|
||||
}
|
||||
|
||||
// Keys returns all keys contained in the current FormData.
|
||||
func (data FormData) Keys() []string {
|
||||
result := make([]string, 0, len(data))
|
||||
|
||||
for k := range data {
|
||||
result = append(result, k)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Keys returns all values contained in the current FormData.
|
||||
func (data FormData) Values() []any {
|
||||
result := make([]any, 0, len(data))
|
||||
|
||||
for _, values := range data {
|
||||
result = append(result, values...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Entries returns a [key, value] slice pair for each FormData entry.
|
||||
func (data FormData) Entries() [][]any {
|
||||
result := make([][]any, 0, len(data))
|
||||
|
||||
for k, values := range data {
|
||||
for _, v := range values {
|
||||
result = append(result, []any{k, v})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// toMultipart converts the current FormData entries into multipart encoded data.
|
||||
func (data FormData) toMultipart() (*bytes.Buffer, *multipart.Writer, error) {
|
||||
body := new(bytes.Buffer)
|
||||
|
||||
mp := multipart.NewWriter(body)
|
||||
defer mp.Close()
|
||||
|
||||
for k, values := range data {
|
||||
for _, rawValue := range values {
|
||||
switch v := rawValue.(type) {
|
||||
case *filesystem.File:
|
||||
err := func() error {
|
||||
mpw, err := mp.CreateFormFile(k, v.OriginalName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := v.Reader.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(mpw, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
default:
|
||||
err := mp.WriteField(k, cast.ToString(v))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body, mp, nil
|
||||
}
|
225
plugins/jsvm/form_data_test.go
Normal file
225
plugins/jsvm/form_data_test.go
Normal file
|
@ -0,0 +1,225 @@
|
|||
package jsvm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestFormDataAppendAndSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
|
||||
data.Append("a", 1)
|
||||
data.Append("a", 2)
|
||||
|
||||
data.Append("b", 3)
|
||||
data.Append("b", 4)
|
||||
data.Set("b", 5) // should overwrite the previous 2 calls
|
||||
|
||||
data.Set("c", 6)
|
||||
data.Set("c", 7)
|
||||
|
||||
if len(data["a"]) != 2 {
|
||||
t.Fatalf("Expected 2 'a' values, got %v", data["a"])
|
||||
}
|
||||
if data["a"][0] != 1 || data["a"][1] != 2 {
|
||||
t.Fatalf("Expected 1 and 2 'a' key values, got %v", data["a"])
|
||||
}
|
||||
|
||||
if len(data["b"]) != 1 {
|
||||
t.Fatalf("Expected 1 'b' values, got %v", data["b"])
|
||||
}
|
||||
if data["b"][0] != 5 {
|
||||
t.Fatalf("Expected 5 as 'b' key value, got %v", data["b"])
|
||||
}
|
||||
|
||||
if len(data["c"]) != 1 {
|
||||
t.Fatalf("Expected 1 'c' values, got %v", data["c"])
|
||||
}
|
||||
if data["c"][0] != 7 {
|
||||
t.Fatalf("Expected 7 as 'c' key value, got %v", data["c"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1)
|
||||
data.Append("a", 2)
|
||||
data.Append("b", 3)
|
||||
|
||||
data.Delete("missing") // should do nothing
|
||||
data.Delete("a")
|
||||
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("Expected exactly 1 data remaining key, got %v", data)
|
||||
}
|
||||
|
||||
if data["b"][0] != 3 {
|
||||
t.Fatalf("Expected 3 as 'b' key value, got %v", data["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1)
|
||||
data.Append("a", 2)
|
||||
|
||||
if v := data.Get("missing"); v != nil {
|
||||
t.Fatalf("Expected %v for key 'missing', got %v", nil, v)
|
||||
}
|
||||
|
||||
if v := data.Get("a"); v != 1 {
|
||||
t.Fatalf("Expected %v for key 'a', got %v", 1, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataGetAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1)
|
||||
data.Append("a", 2)
|
||||
|
||||
if v := data.GetAll("missing"); v != nil {
|
||||
t.Fatalf("Expected %v for key 'a', got %v", nil, v)
|
||||
}
|
||||
|
||||
values := data.GetAll("a")
|
||||
if len(values) != 2 || values[0] != 1 || values[1] != 2 {
|
||||
t.Fatalf("Expected 1 and 2 values, got %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataHas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1)
|
||||
|
||||
if v := data.Has("missing"); v {
|
||||
t.Fatalf("Expected key 'missing' to not exist: %v", v)
|
||||
}
|
||||
|
||||
if v := data.Has("a"); !v {
|
||||
t.Fatalf("Expected key 'a' to exist: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1)
|
||||
data.Append("b", 1)
|
||||
data.Append("c", 1)
|
||||
data.Append("a", 1)
|
||||
|
||||
keys := data.Keys()
|
||||
|
||||
expectedKeys := []string{"a", "b", "c"}
|
||||
|
||||
for _, expected := range expectedKeys {
|
||||
if !list.ExistInSlice(expected, keys) {
|
||||
t.Fatalf("Expected key %s to exists in %v", expected, keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1)
|
||||
data.Append("b", 2)
|
||||
data.Append("c", 3)
|
||||
data.Append("a", 4)
|
||||
|
||||
values := data.Values()
|
||||
|
||||
expectedKeys := []any{1, 2, 3, 4}
|
||||
|
||||
for _, expected := range expectedKeys {
|
||||
if !list.ExistInSlice(expected, values) {
|
||||
t.Fatalf("Expected key %s to exists in %v", expected, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1)
|
||||
data.Append("b", 2)
|
||||
data.Append("c", 3)
|
||||
data.Append("a", 4)
|
||||
|
||||
entries := data.Entries()
|
||||
|
||||
rawEntries, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(entries) != 4 {
|
||||
t.Fatalf("Expected 4 entries")
|
||||
}
|
||||
|
||||
expectedEntries := []string{`["a",1]`, `["a",4]`, `["b",2]`, `["c",3]`}
|
||||
for _, expected := range expectedEntries {
|
||||
if !bytes.Contains(rawEntries, []byte(expected)) {
|
||||
t.Fatalf("Expected entry %s to exists in %s", expected, rawEntries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataToMultipart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, err := filesystem.NewFileFromBytes([]byte("abc"), "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data := FormData{}
|
||||
data.Append("a", 1) // should be casted
|
||||
data.Append("b", "test1")
|
||||
data.Append("b", "test2")
|
||||
data.Append("c", f)
|
||||
|
||||
body, mp, err := data.toMultipart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bodyStr := body.String()
|
||||
|
||||
// content type checks
|
||||
contentType := mp.FormDataContentType()
|
||||
expectedContentType := "multipart/form-data; boundary="
|
||||
if !strings.Contains(contentType, expectedContentType) {
|
||||
t.Fatalf("Expected to find content-type %s in %s", expectedContentType, contentType)
|
||||
}
|
||||
|
||||
// body checks
|
||||
expectedBodyParts := []string{
|
||||
"Content-Disposition: form-data; name=\"a\"\r\n\r\n1",
|
||||
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest1",
|
||||
"Content-Disposition: form-data; name=\"b\"\r\n\r\ntest2",
|
||||
"Content-Disposition: form-data; name=\"c\"; filename=\"test\"\r\nContent-Type: application/octet-stream\r\n\r\nabc",
|
||||
}
|
||||
for _, part := range expectedBodyParts {
|
||||
if !strings.Contains(bodyStr, part) {
|
||||
t.Fatalf("Expected to find %s in body\n%s", part, bodyStr)
|
||||
}
|
||||
}
|
||||
}
|
6
plugins/jsvm/internal/types/generated/embed.go
Normal file
6
plugins/jsvm/internal/types/generated/embed.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package generated
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed types.d.ts
|
||||
var Types embed.FS
|
23030
plugins/jsvm/internal/types/generated/types.d.ts
vendored
Normal file
23030
plugins/jsvm/internal/types/generated/types.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load diff
1258
plugins/jsvm/internal/types/types.go
Normal file
1258
plugins/jsvm/internal/types/types.go
Normal file
File diff suppressed because it is too large
Load diff
550
plugins/jsvm/jsvm.go
Normal file
550
plugins/jsvm/jsvm.go
Normal file
|
@ -0,0 +1,550 @@
|
|||
// Package jsvm implements pluggable utilities for binding a JS goja runtime
|
||||
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// jsvm.MustRegister(app, jsvm.Config{
|
||||
// HooksWatch: true,
|
||||
// })
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/buffer"
|
||||
"github.com/dop251/goja_nodejs/console"
|
||||
"github.com/dop251/goja_nodejs/process"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm/internal/types/generated"
|
||||
"github.com/pocketbase/pocketbase/tools/template"
|
||||
)
|
||||
|
||||
const typesFileName = "types.d.ts"
|
||||
|
||||
var defaultScriptPath = "pb.js"
|
||||
|
||||
func init() {
|
||||
// For backward compatibility and consistency with the Go exposed
|
||||
// methods that operate with relative paths (e.g. `$os.writeFile`),
|
||||
// we define the "current JS module" as if it is a file in the current working directory
|
||||
// (the filename itself doesn't really matter and in our case the hook handlers are executed as separate "programs").
|
||||
//
|
||||
// This is necessary for `require(module)` to properly traverse parents node_modules (goja_nodejs#95).
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
// truly rare case, log just for debug purposes
|
||||
color.Yellow("Failed to retrieve the current working directory: %v", err)
|
||||
} else {
|
||||
defaultScriptPath = filepath.Join(cwd, defaultScriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Config defines the config options of the jsvm plugin.
|
||||
type Config struct {
|
||||
// OnInit is an optional function that will be called
|
||||
// after a JS runtime is initialized, allowing you to
|
||||
// attach custom Go variables and functions.
|
||||
OnInit func(vm *goja.Runtime)
|
||||
|
||||
// HooksWatch enables auto app restarts when a JS app hook file changes.
|
||||
//
|
||||
// Note that currently the application cannot be automatically restarted on Windows
|
||||
// because the restart process relies on execve.
|
||||
HooksWatch bool
|
||||
|
||||
// HooksDir specifies the JS app hooks directory.
|
||||
//
|
||||
// If not set it fallbacks to a relative "pb_data/../pb_hooks" directory.
|
||||
HooksDir string
|
||||
|
||||
// HooksFilesPattern specifies a regular expression pattern that
|
||||
// identify which file to load by the hook vm(s).
|
||||
//
|
||||
// If not set it fallbacks to `^.*(\.pb\.js|\.pb\.ts)$`, aka. any
|
||||
// HookdsDir file ending in ".pb.js" or ".pb.ts" (the last one is to enforce IDE linters).
|
||||
HooksFilesPattern string
|
||||
|
||||
// HooksPoolSize specifies how many goja.Runtime instances to prewarm
|
||||
// and keep for the JS app hooks gorotines execution.
|
||||
//
|
||||
// Zero or negative value means that it will create a new goja.Runtime
|
||||
// on every fired goroutine.
|
||||
HooksPoolSize int
|
||||
|
||||
// MigrationsDir specifies the JS migrations directory.
|
||||
//
|
||||
// If not set it fallbacks to a relative "pb_data/../pb_migrations" directory.
|
||||
MigrationsDir string
|
||||
|
||||
// If not set it fallbacks to `^.*(\.js|\.ts)$`, aka. any MigrationDir file
|
||||
// ending in ".js" or ".ts" (the last one is to enforce IDE linters).
|
||||
MigrationsFilesPattern string
|
||||
|
||||
// TypesDir specifies the directory where to store the embedded
|
||||
// TypeScript declarations file.
|
||||
//
|
||||
// If not set it fallbacks to "pb_data".
|
||||
//
|
||||
// Note: Avoid using the same directory as the HooksDir when HooksWatch is enabled
|
||||
// to prevent unnecessary app restarts when the types file is initially created.
|
||||
TypesDir string
|
||||
}
|
||||
|
||||
// MustRegister registers the jsvm plugin in the provided app instance
|
||||
// and panics if it fails.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// jsvm.MustRegister(app, jsvm.Config{
|
||||
// OnInit: func(vm *goja.Runtime) {
|
||||
// // register custom bindings
|
||||
// vm.Set("myCustomVar", 123)
|
||||
// },
|
||||
// })
|
||||
func MustRegister(app core.App, config Config) {
|
||||
if err := Register(app, config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers the jsvm plugin in the provided app instance.
|
||||
func Register(app core.App, config Config) error {
|
||||
p := &plugin{app: app, config: config}
|
||||
|
||||
if p.config.HooksDir == "" {
|
||||
p.config.HooksDir = filepath.Join(app.DataDir(), "../pb_hooks")
|
||||
}
|
||||
|
||||
if p.config.MigrationsDir == "" {
|
||||
p.config.MigrationsDir = filepath.Join(app.DataDir(), "../pb_migrations")
|
||||
}
|
||||
|
||||
if p.config.HooksFilesPattern == "" {
|
||||
p.config.HooksFilesPattern = `^.*(\.pb\.js|\.pb\.ts)$`
|
||||
}
|
||||
|
||||
if p.config.MigrationsFilesPattern == "" {
|
||||
p.config.MigrationsFilesPattern = `^.*(\.js|\.ts)$`
|
||||
}
|
||||
|
||||
if p.config.TypesDir == "" {
|
||||
p.config.TypesDir = app.DataDir()
|
||||
}
|
||||
|
||||
p.app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure that the user has the latest types declaration
|
||||
err = p.refreshTypesFile()
|
||||
if err != nil {
|
||||
color.Yellow("Unable to refresh app types file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := p.registerMigrations(); err != nil {
|
||||
return fmt.Errorf("registerMigrations: %w", err)
|
||||
}
|
||||
|
||||
if err := p.registerHooks(); err != nil {
|
||||
return fmt.Errorf("registerHooks: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type plugin struct {
|
||||
app core.App
|
||||
config Config
|
||||
}
|
||||
|
||||
// registerMigrations registers the JS migrations loader.
|
||||
func (p *plugin) registerMigrations() error {
|
||||
// fetch all js migrations sorted by their filename
|
||||
files, err := filesContent(p.config.MigrationsDir, p.config.MigrationsFilesPattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := new(require.Registry) // this can be shared by multiple runtimes
|
||||
|
||||
for file, content := range files {
|
||||
vm := goja.New()
|
||||
|
||||
registry.Enable(vm)
|
||||
console.Enable(vm)
|
||||
process.Enable(vm)
|
||||
buffer.Enable(vm)
|
||||
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
securityBinds(vm)
|
||||
osBinds(vm)
|
||||
filepathBinds(vm)
|
||||
httpClientBinds(vm)
|
||||
|
||||
vm.Set("migrate", func(up, down func(txApp core.App) error) {
|
||||
core.AppMigrations.Register(up, down, file)
|
||||
})
|
||||
|
||||
if p.config.OnInit != nil {
|
||||
p.config.OnInit(vm)
|
||||
}
|
||||
|
||||
_, err := vm.RunScript(defaultScriptPath, string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerHooks registers the JS app hooks loader.
|
||||
func (p *plugin) registerHooks() error {
|
||||
// fetch all js hooks sorted by their filename
|
||||
files, err := filesContent(p.config.HooksDir, p.config.HooksFilesPattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// prepend the types reference directive
|
||||
//
|
||||
// note: it is loaded during startup to handle conveniently also
|
||||
// the case when the HooksWatch option is enabled and the application
|
||||
// restart on newly created file
|
||||
for name, content := range files {
|
||||
if len(content) != 0 {
|
||||
// skip non-empty files for now to prevent accidental overwrite
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(p.config.HooksDir, name)
|
||||
directive := `/// <reference path="` + p.relativeTypesPath(p.config.HooksDir) + `" />`
|
||||
if err := prependToEmptyFile(path, directive+"\n\n"); err != nil {
|
||||
color.Yellow("Unable to prepend the types reference: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initialize the hooks dir watcher
|
||||
if p.config.HooksWatch {
|
||||
if err := p.watchHooks(); err != nil {
|
||||
color.Yellow("Unable to init hooks watcher: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
// no need to register the vms since there are no entrypoint files anyway
|
||||
return nil
|
||||
}
|
||||
|
||||
absHooksDir, err := filepath.Abs(p.config.HooksDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||
e.Router.BindFunc(p.normalizeServeExceptions)
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
// safe to be shared across multiple vms
|
||||
requireRegistry := new(require.Registry)
|
||||
templateRegistry := template.NewRegistry()
|
||||
|
||||
sharedBinds := func(vm *goja.Runtime) {
|
||||
requireRegistry.Enable(vm)
|
||||
console.Enable(vm)
|
||||
process.Enable(vm)
|
||||
buffer.Enable(vm)
|
||||
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
filesystemBinds(vm)
|
||||
securityBinds(vm)
|
||||
osBinds(vm)
|
||||
filepathBinds(vm)
|
||||
httpClientBinds(vm)
|
||||
formsBinds(vm)
|
||||
apisBinds(vm)
|
||||
mailsBinds(vm)
|
||||
|
||||
vm.Set("$app", p.app)
|
||||
vm.Set("$template", templateRegistry)
|
||||
vm.Set("__hooks", absHooksDir)
|
||||
|
||||
if p.config.OnInit != nil {
|
||||
p.config.OnInit(vm)
|
||||
}
|
||||
}
|
||||
|
||||
// initiliaze the executor vms
|
||||
executors := newPool(p.config.HooksPoolSize, func() *goja.Runtime {
|
||||
executor := goja.New()
|
||||
sharedBinds(executor)
|
||||
return executor
|
||||
})
|
||||
|
||||
// initialize the loader vm
|
||||
loader := goja.New()
|
||||
sharedBinds(loader)
|
||||
hooksBinds(p.app, loader, executors)
|
||||
cronBinds(p.app, loader, executors)
|
||||
routerBinds(p.app, loader, executors)
|
||||
|
||||
for file, content := range files {
|
||||
func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
fmtErr := fmt.Errorf("failed to execute %s:\n - %v", file, err)
|
||||
|
||||
if p.config.HooksWatch {
|
||||
color.Red("%v", fmtErr)
|
||||
} else {
|
||||
panic(fmtErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_, err := loader.RunScript(defaultScriptPath, string(content))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeExceptions registers a global error handler that
|
||||
// wraps the extracted goja exception error value for consistency
|
||||
// when throwing or returning errors.
|
||||
func (p *plugin) normalizeServeExceptions(e *core.RequestEvent) error {
|
||||
err := e.Next()
|
||||
|
||||
if err == nil || e.Written() {
|
||||
return err // no error or already committed
|
||||
}
|
||||
|
||||
return normalizeException(err)
|
||||
}
|
||||
|
||||
// watchHooks initializes a hooks file watcher that will restart the
|
||||
// application (*if possible) in case of a change in the hooks directory.
|
||||
//
|
||||
// This method does nothing if the hooks directory is missing.
|
||||
func (p *plugin) watchHooks() error {
|
||||
watchDir := p.config.HooksDir
|
||||
|
||||
hooksDirInfo, err := os.Lstat(p.config.HooksDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil // no hooks dir to watch
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if hooksDirInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
watchDir, err = filepath.EvalSymlinks(p.config.HooksDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hooksDir symink: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var debounceTimer *time.Timer
|
||||
|
||||
stopDebounceTimer := func() {
|
||||
if debounceTimer != nil {
|
||||
debounceTimer.Stop()
|
||||
debounceTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
p.app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
|
||||
watcher.Close()
|
||||
|
||||
stopDebounceTimer()
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
// start listening for events.
|
||||
go func() {
|
||||
defer stopDebounceTimer()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
stopDebounceTimer()
|
||||
|
||||
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
|
||||
// app restart is currently not supported on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
color.Yellow("File %s changed, please restart the app manually", event.Name)
|
||||
} else {
|
||||
color.Yellow("File %s changed, restarting...", event.Name)
|
||||
if err := p.app.Restart(); err != nil {
|
||||
color.Red("Failed to restart the app:", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
color.Red("Watch error:", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// add directories to watch
|
||||
//
|
||||
// @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18)
|
||||
dirsErr := filepath.WalkDir(watchDir, func(path string, entry fs.DirEntry, err error) error {
|
||||
// ignore hidden directories, node_modules, symlinks, sockets, etc.
|
||||
if !entry.IsDir() || entry.Name() == "node_modules" || strings.HasPrefix(entry.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
return watcher.Add(path)
|
||||
})
|
||||
if dirsErr != nil {
|
||||
watcher.Close()
|
||||
}
|
||||
|
||||
return dirsErr
|
||||
}
|
||||
|
||||
// fullTypesPathReturns returns the full path to the generated TS file.
|
||||
func (p *plugin) fullTypesPath() string {
|
||||
return filepath.Join(p.config.TypesDir, typesFileName)
|
||||
}
|
||||
|
||||
// relativeTypesPath returns a path to the generated TS file relative
|
||||
// to the specified basepath.
|
||||
//
|
||||
// It fallbacks to the full path if generating the relative path fails.
|
||||
func (p *plugin) relativeTypesPath(basepath string) string {
|
||||
fullPath := p.fullTypesPath()
|
||||
|
||||
rel, err := filepath.Rel(basepath, fullPath)
|
||||
if err != nil {
|
||||
// fallback to the full path
|
||||
rel = fullPath
|
||||
}
|
||||
|
||||
return rel
|
||||
}
|
||||
|
||||
// refreshTypesFile saves the embedded TS declarations as a file on the disk.
|
||||
func (p *plugin) refreshTypesFile() error {
|
||||
fullPath := p.fullTypesPath()
|
||||
|
||||
// ensure that the types directory exists
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// retrieve the types data to write
|
||||
data, err := generated.Types.ReadFile(typesFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// read the first timestamp line of the old file (if exists) and compare it to the embedded one
|
||||
// (note: ignore errors to allow always overwriting the file if it is invalid)
|
||||
existingFile, err := os.Open(fullPath)
|
||||
if err == nil {
|
||||
timestamp := make([]byte, 13)
|
||||
io.ReadFull(existingFile, timestamp)
|
||||
existingFile.Close()
|
||||
|
||||
if len(data) >= len(timestamp) && bytes.Equal(data[:13], timestamp) {
|
||||
return nil // nothing new to save
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(fullPath, data, 0644)
|
||||
}
|
||||
|
||||
// prependToEmptyFile prepends the specified text to an empty file.
|
||||
//
|
||||
// If the file is not empty this method does nothing.
|
||||
func prependToEmptyFile(path, text string) error {
|
||||
info, err := os.Stat(path)
|
||||
|
||||
if err == nil && info.Size() == 0 {
|
||||
return os.WriteFile(path, []byte(text), 0644)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// filesContent returns a map with all direct files within the specified dir and their content.
|
||||
//
|
||||
// If directory with dirPath is missing or no files matching the pattern were found,
|
||||
// it returns an empty map and no error.
|
||||
//
|
||||
// If pattern is empty string it matches all root files.
|
||||
func filesContent(dirPath string, pattern string) (map[string][]byte, error) {
|
||||
files, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return map[string][]byte{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var exp *regexp.Regexp
|
||||
if pattern != "" {
|
||||
var err error
|
||||
if exp, err = regexp.Compile(pattern); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string][]byte{}
|
||||
|
||||
for _, f := range files {
|
||||
if f.IsDir() || (exp != nil && !exp.MatchString(f.Name())) {
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result[f.Name()] = raw
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
67
plugins/jsvm/mapper.go
Normal file
67
plugins/jsvm/mapper.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
package jsvm
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var (
|
||||
_ goja.FieldNameMapper = (*FieldMapper)(nil)
|
||||
)
|
||||
|
||||
// FieldMapper provides custom mapping between Go and JavaScript property names.
|
||||
//
|
||||
// It is similar to the builtin "uncapFieldNameMapper" but also converts
|
||||
// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get").
|
||||
type FieldMapper struct {
|
||||
}
|
||||
|
||||
// FieldName implements the [FieldNameMapper.FieldName] interface method.
|
||||
func (u FieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string {
|
||||
return convertGoToJSName(f.Name)
|
||||
}
|
||||
|
||||
// MethodName implements the [FieldNameMapper.MethodName] interface method.
|
||||
func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string {
|
||||
return convertGoToJSName(m.Name)
|
||||
}
|
||||
|
||||
var nameExceptions = map[string]string{"OAuth2": "oauth2"}
|
||||
|
||||
func convertGoToJSName(name string) string {
|
||||
if v, ok := nameExceptions[name]; ok {
|
||||
return v
|
||||
}
|
||||
|
||||
startUppercase := make([]rune, 0, len(name))
|
||||
|
||||
for _, c := range name {
|
||||
if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) {
|
||||
break
|
||||
}
|
||||
|
||||
startUppercase = append(startUppercase, c)
|
||||
}
|
||||
|
||||
totalStartUppercase := len(startUppercase)
|
||||
|
||||
// all uppercase eg. "JSON" -> "json"
|
||||
if len(name) == totalStartUppercase {
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
|
||||
// eg. "JSONField" -> "jsonField"
|
||||
if totalStartUppercase > 1 {
|
||||
return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:]
|
||||
}
|
||||
|
||||
// eg. "GetField" -> "getField"
|
||||
if totalStartUppercase == 1 {
|
||||
return strings.ToLower(name[0:1]) + name[1:]
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
42
plugins/jsvm/mapper_test.go
Normal file
42
plugins/jsvm/mapper_test.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
package jsvm_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
)
|
||||
|
||||
func TestFieldMapper(t *testing.T) {
|
||||
mapper := jsvm.FieldMapper{}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"test", "test"},
|
||||
{"Test", "test"},
|
||||
{"miXeD", "miXeD"},
|
||||
{"MiXeD", "miXeD"},
|
||||
{"ResolveRequestAsJSON", "resolveRequestAsJSON"},
|
||||
{"Variable_with_underscore", "variable_with_underscore"},
|
||||
{"ALLCAPS", "allcaps"},
|
||||
{"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"},
|
||||
{"OIDCMap", "oidcMap"},
|
||||
{"MD5", "md5"},
|
||||
{"OAuth2", "oauth2"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
field := reflect.StructField{Name: s.name}
|
||||
if v := mapper.FieldName(nil, field); v != s.expected {
|
||||
t.Fatalf("[%d] Expected FieldName %q, got %q", i, s.expected, v)
|
||||
}
|
||||
|
||||
method := reflect.Method{Name: s.name}
|
||||
if v := mapper.MethodName(nil, method); v != s.expected {
|
||||
t.Fatalf("[%d] Expected MethodName %q, got %q", i, s.expected, v)
|
||||
}
|
||||
}
|
||||
}
|
73
plugins/jsvm/pool.go
Normal file
73
plugins/jsvm/pool.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
package jsvm
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type poolItem struct {
|
||||
mux sync.Mutex
|
||||
busy bool
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
type vmsPool struct {
|
||||
mux sync.RWMutex
|
||||
factory func() *goja.Runtime
|
||||
items []*poolItem
|
||||
}
|
||||
|
||||
// newPool creates a new pool with pre-warmed vms generated from the specified factory.
|
||||
func newPool(size int, factory func() *goja.Runtime) *vmsPool {
|
||||
pool := &vmsPool{
|
||||
factory: factory,
|
||||
items: make([]*poolItem, size),
|
||||
}
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
vm := pool.factory()
|
||||
pool.items[i] = &poolItem{vm: vm}
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
// run executes "call" with a vm created from the pool
|
||||
// (either from the buffer or a new one if all buffered vms are busy)
|
||||
func (p *vmsPool) run(call func(vm *goja.Runtime) error) error {
|
||||
p.mux.RLock()
|
||||
|
||||
// try to find a free item
|
||||
var freeItem *poolItem
|
||||
for _, item := range p.items {
|
||||
item.mux.Lock()
|
||||
if item.busy {
|
||||
item.mux.Unlock()
|
||||
continue
|
||||
}
|
||||
item.busy = true
|
||||
item.mux.Unlock()
|
||||
freeItem = item
|
||||
break
|
||||
}
|
||||
|
||||
p.mux.RUnlock()
|
||||
|
||||
// create a new one-off item if of all of the pool items are currently busy
|
||||
//
|
||||
// note: if turned out not efficient we may change this in the future
|
||||
// by adding the created item in the pool with some timer for removal
|
||||
if freeItem == nil {
|
||||
return call(p.factory())
|
||||
}
|
||||
|
||||
execErr := call(freeItem.vm)
|
||||
|
||||
// "free" the vm
|
||||
freeItem.mux.Lock()
|
||||
freeItem.busy = false
|
||||
freeItem.mux.Unlock()
|
||||
|
||||
return execErr
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue