@x47base/pocketbase-addon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +9 -0
- package/Dockerfile +26 -0
- package/FEATURES.md +32 -0
- package/LICENSE.md +17 -0
- package/MIGRATION.md +49 -0
- package/NOTICE.md +5 -0
- package/README.md +26 -0
- package/adapter.go +31 -0
- package/admin/register.go +60 -0
- package/admin/register_test.go +37 -0
- package/backups/backup_encryption_test.go +82 -0
- package/backups/encryption.go +138 -0
- package/backups/integration_test.go +51 -0
- package/backups/register.go +120 -0
- package/backups/restore.go +119 -0
- package/backups/s3_test.go +52 -0
- package/backups/swap.go +53 -0
- package/backups/swap_test.go +75 -0
- package/backups/upload.go +52 -0
- package/bin/pocketbase-extension.mjs +25 -0
- package/cmd/edge/main.go +123 -0
- package/cmd/import-fork/main.go +37 -0
- package/cmd/loadtest/main.go +61 -0
- package/cmd/loadtest/sandbox.go +73 -0
- package/cmd/loadtest/sandbox_test.go +20 -0
- package/cmd/pocketbase/main.go +78 -0
- package/deploy/README.md +148 -0
- package/deploy/app/hooks/README.md +2 -0
- package/deploy/app/migrations/1789000000_notes.js +16 -0
- package/deploy/app/public/README.md +2 -0
- package/deploy/compose.secrets.yaml +8 -0
- package/deploy/compose.yaml +68 -0
- package/deploy/edge.json +13 -0
- package/edge/gateway.go +295 -0
- package/edge/gateway_test.go +296 -0
- package/edge/openapi.json +1 -0
- package/edge/policy.go +150 -0
- package/features/collection_singleton.go +13 -0
- package/features/collection_singleton_test.go +46 -0
- package/features/dimensions_test.go +65 -0
- package/features/duplicate.go +178 -0
- package/features/duplicate_test.go +128 -0
- package/features/field_color.go +46 -0
- package/features/field_date_only.go +39 -0
- package/features/field_json_schema.go +92 -0
- package/features/field_scalar_extensions_test.go +66 -0
- package/features/files.go +36 -0
- package/features/filter_has_any_test.go +81 -0
- package/features/generate_test.go +72 -0
- package/features/has_any_visibility_test.go +62 -0
- package/features/json.go +50 -0
- package/features/membership.go +64 -0
- package/features/register.go +45 -0
- package/features/schema_test.go +58 -0
- package/features/ui/main.js +133 -0
- package/features/ui/settings.js +31 -0
- package/go.mod +54 -0
- package/go.sum +159 -0
- package/internal/archive/create.go +91 -0
- package/internal/archive/create_test.go +125 -0
- package/internal/archive/extract.go +99 -0
- package/internal/archive/extract_test.go +88 -0
- package/jsvm/binds.go +1273 -0
- package/jsvm/binds_app_reset_test.go +314 -0
- package/jsvm/binds_test.go +1870 -0
- package/jsvm/form_data.go +149 -0
- package/jsvm/form_data_test.go +225 -0
- package/jsvm/internal/types/generated/embed.go +6 -0
- package/jsvm/internal/types/generated/types.d.ts +24820 -0
- package/jsvm/internal/types/types.go +1408 -0
- package/jsvm/jsvm.go +587 -0
- package/jsvm/mapper.go +67 -0
- package/jsvm/mapper_test.go +42 -0
- package/jsvm/pool.go +73 -0
- package/jsvm/program_source_test.go +24 -0
- package/loadtest/loadtest.go +202 -0
- package/loadtest/loadtest_test.go +84 -0
- package/localization/README.md +23 -0
- package/localization/catalogue.json +483 -0
- package/localization/localization.go +94 -0
- package/localization/localization_test.go +21 -0
- package/mail/register.go +80 -0
- package/mail/register_test.go +49 -0
- package/mail/resolve.go +91 -0
- package/migration/import.go +96 -0
- package/migration/import_test.go +58 -0
- package/otp/otp.go +56 -0
- package/otp/otp_test.go +56 -0
- package/package.json +51 -0
- package/scripts/check-edge.py +42 -0
- package/scripts/check.sh +11 -0
- package/scripts/sync-jsvm-types.sh +10 -0
- package/security/README.md +94 -0
- package/security/assurance_test.go +149 -0
- package/security/compatibility_test.go +128 -0
- package/security/config.go +78 -0
- package/security/dashboard_test.go +103 -0
- package/security/management.go +169 -0
- package/security/openapi.json +508 -0
- package/security/review.go +34 -0
- package/security/security.go +503 -0
- package/security/security_test.go +146 -0
- package/security/state.go +116 -0
- package/security/ui/dashboard.css +4 -0
- package/security/ui/dashboard.js +83 -0
- package/security/ui/main.js +15 -0
- package/security/ui/model.js +32 -0
- package/security/ui/model.test.mjs +25 -0
- package/security/ui/registration.test.mjs +10 -0
- package/settings/env_test.go +41 -0
- package/settings/openapi.json +193 -0
- package/settings/settings.go +155 -0
- package/settings/settings_test.go +31 -0
- package/watcher/watcher.go +192 -0
- package/watcher/watcher_test.go +200 -0
- package/web/static.go +99 -0
- package/web/static_test.go +48 -0
package/jsvm/jsvm.go
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// Package jsvm implements pluggable utilities for binding a JS goja runtime
|
|
2
|
+
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
|
|
3
|
+
//
|
|
4
|
+
// The package also exports several reusable bindings so that users
|
|
5
|
+
// can utilize them as part of their own custom goja runtime setup.
|
|
6
|
+
//
|
|
7
|
+
// Example:
|
|
8
|
+
//
|
|
9
|
+
// jsvm.MustRegister(app, jsvm.Config{
|
|
10
|
+
// HooksWatch: true,
|
|
11
|
+
// })
|
|
12
|
+
package jsvm
|
|
13
|
+
|
|
14
|
+
import (
|
|
15
|
+
"bytes"
|
|
16
|
+
"errors"
|
|
17
|
+
"fmt"
|
|
18
|
+
"io"
|
|
19
|
+
"io/fs"
|
|
20
|
+
"os"
|
|
21
|
+
"path/filepath"
|
|
22
|
+
"regexp"
|
|
23
|
+
"runtime"
|
|
24
|
+
"strings"
|
|
25
|
+
"time"
|
|
26
|
+
|
|
27
|
+
"github.com/dop251/goja"
|
|
28
|
+
"github.com/dop251/goja_nodejs/buffer"
|
|
29
|
+
"github.com/dop251/goja_nodejs/console"
|
|
30
|
+
"github.com/dop251/goja_nodejs/process"
|
|
31
|
+
"github.com/dop251/goja_nodejs/require"
|
|
32
|
+
"github.com/fatih/color"
|
|
33
|
+
"github.com/fsnotify/fsnotify"
|
|
34
|
+
"github.com/pocketbase/pocketbase/core"
|
|
35
|
+
"github.com/spink-dev/pocketbase-extension/jsvm/internal/types/generated"
|
|
36
|
+
"github.com/pocketbase/pocketbase/tools/routine"
|
|
37
|
+
"github.com/pocketbase/pocketbase/tools/template"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const typesFileName = "types.d.ts"
|
|
41
|
+
|
|
42
|
+
var defaultScriptPath = "pb.js"
|
|
43
|
+
|
|
44
|
+
func init() {
|
|
45
|
+
// For backward compatibility and consistency with the Go exposed
|
|
46
|
+
// methods that operate with relative paths (e.g. `$os.writeFile`),
|
|
47
|
+
// we define the "current JS module" as if it is a file in the current working directory
|
|
48
|
+
// (the filename itself doesn't really matter and in our case the hook handlers are executed as separate "programs").
|
|
49
|
+
//
|
|
50
|
+
// This is necessary for `require(module)` to properly traverse parents node_modules (goja_nodejs#95).
|
|
51
|
+
cwd, err := os.Getwd()
|
|
52
|
+
if err != nil {
|
|
53
|
+
// truly rare case, log just for debug purposes
|
|
54
|
+
color.Yellow("Failed to retrieve the current working directory: %v", err)
|
|
55
|
+
} else {
|
|
56
|
+
defaultScriptPath = filepath.Join(cwd, defaultScriptPath)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Config defines the config options of the jsvm plugin.
|
|
61
|
+
type Config struct {
|
|
62
|
+
ProgramSource func(name, source string) (*goja.Program, error)
|
|
63
|
+
|
|
64
|
+
// OnInit is an optional function that will be called
|
|
65
|
+
// after a JS runtime is initialized, allowing you to
|
|
66
|
+
// attach custom Go variables and functions.
|
|
67
|
+
OnInit func(vm *goja.Runtime)
|
|
68
|
+
|
|
69
|
+
// HooksWatch enables auto app restarts when a JS app hook file changes.
|
|
70
|
+
//
|
|
71
|
+
// Note that currently the application cannot be automatically restarted on Windows
|
|
72
|
+
// because the restart process relies on execve.
|
|
73
|
+
HooksWatch bool
|
|
74
|
+
|
|
75
|
+
// HooksDir specifies the JS app hooks directory.
|
|
76
|
+
//
|
|
77
|
+
// If not set it fallbacks to a relative "pb_data/../pb_hooks" directory.
|
|
78
|
+
HooksDir string
|
|
79
|
+
|
|
80
|
+
// HooksFilesPattern specifies a regular expression pattern that
|
|
81
|
+
// identify which file to load by the hook vm(s).
|
|
82
|
+
//
|
|
83
|
+
// If not set it fallbacks to `^.*(\.pb\.js|\.pb\.ts)$`, aka. any
|
|
84
|
+
// HookdsDir file ending in ".pb.js" or ".pb.ts" (the last one is to enforce IDE linters).
|
|
85
|
+
HooksFilesPattern string
|
|
86
|
+
|
|
87
|
+
// HooksPoolSize specifies how many goja.Runtime instances to prewarm
|
|
88
|
+
// and keep for the JS app hooks gorotines execution.
|
|
89
|
+
//
|
|
90
|
+
// Zero or negative value means that it will create a new goja.Runtime
|
|
91
|
+
// on every fired goroutine.
|
|
92
|
+
HooksPoolSize int
|
|
93
|
+
|
|
94
|
+
// MigrationsDir specifies the JS migrations directory.
|
|
95
|
+
//
|
|
96
|
+
// If not set it fallbacks to a relative "pb_data/../pb_migrations" directory.
|
|
97
|
+
MigrationsDir string
|
|
98
|
+
|
|
99
|
+
// If not set it fallbacks to `^.*(\.js|\.ts)$`, aka. any MigrationDir file
|
|
100
|
+
// ending in ".js" or ".ts" (the last one is to enforce IDE linters).
|
|
101
|
+
MigrationsFilesPattern string
|
|
102
|
+
|
|
103
|
+
// TypesDir specifies the directory where to store the embedded
|
|
104
|
+
// TypeScript declarations file.
|
|
105
|
+
//
|
|
106
|
+
// If not set it fallbacks to "pb_data".
|
|
107
|
+
//
|
|
108
|
+
// Note: Avoid using the same directory as the HooksDir when HooksWatch is enabled
|
|
109
|
+
// to prevent unnecessary app restarts when the types file is initially created.
|
|
110
|
+
TypesDir string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// MustRegister registers the jsvm plugin in the provided app instance
|
|
114
|
+
// and panics if it fails.
|
|
115
|
+
//
|
|
116
|
+
// Example usage:
|
|
117
|
+
//
|
|
118
|
+
// jsvm.MustRegister(app, jsvm.Config{
|
|
119
|
+
// OnInit: func(vm *goja.Runtime) {
|
|
120
|
+
// // register custom bindings
|
|
121
|
+
// vm.Set("myCustomVar", 123)
|
|
122
|
+
// },
|
|
123
|
+
// })
|
|
124
|
+
func MustRegister(app core.App, config Config) {
|
|
125
|
+
if err := Register(app, config); err != nil {
|
|
126
|
+
panic(err)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Register registers the jsvm plugin in the provided app instance.
|
|
131
|
+
func Register(app core.App, config Config) error {
|
|
132
|
+
p := &plugin{app: app, config: config}
|
|
133
|
+
|
|
134
|
+
if p.config.HooksDir == "" {
|
|
135
|
+
p.config.HooksDir = filepath.Join(app.DataDir(), "../pb_hooks")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if p.config.MigrationsDir == "" {
|
|
139
|
+
p.config.MigrationsDir = filepath.Join(app.DataDir(), "../pb_migrations")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if p.config.HooksFilesPattern == "" {
|
|
143
|
+
p.config.HooksFilesPattern = `^.*(\.pb\.js|\.pb\.ts)$`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if p.config.MigrationsFilesPattern == "" {
|
|
147
|
+
p.config.MigrationsFilesPattern = `^.*(\.js|\.ts)$`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if p.config.TypesDir == "" {
|
|
151
|
+
p.config.TypesDir = app.DataDir()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
p.app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
|
|
155
|
+
err := e.Next()
|
|
156
|
+
if err != nil {
|
|
157
|
+
return err
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ensure that the user has the latest types declaration
|
|
161
|
+
err = p.refreshTypesFile()
|
|
162
|
+
if err != nil {
|
|
163
|
+
color.Yellow("Unable to refresh app types file: %v", err)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return nil
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
if err := p.registerMigrations(); err != nil {
|
|
170
|
+
return fmt.Errorf("registerMigrations: %w", err)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if err := p.registerHooks(); err != nil {
|
|
174
|
+
return fmt.Errorf("registerHooks: %w", err)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type plugin struct {
|
|
181
|
+
app core.App
|
|
182
|
+
config Config
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// registerMigrations registers the JS migrations loader.
|
|
186
|
+
func (p *plugin) registerMigrations() error {
|
|
187
|
+
// fetch all js migrations sorted by their filename
|
|
188
|
+
files, err := filesContent(p.config.MigrationsDir, p.config.MigrationsFilesPattern)
|
|
189
|
+
if err != nil {
|
|
190
|
+
return err
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
absHooksDir, err := filepath.Abs(p.config.HooksDir)
|
|
194
|
+
if err != nil {
|
|
195
|
+
return err
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
registry := new(require.Registry) // this can be shared by multiple runtimes
|
|
199
|
+
templateRegistry := template.NewRegistry()
|
|
200
|
+
|
|
201
|
+
for file, content := range files {
|
|
202
|
+
vm := goja.New()
|
|
203
|
+
|
|
204
|
+
registry.Enable(vm)
|
|
205
|
+
console.Enable(vm)
|
|
206
|
+
process.Enable(vm)
|
|
207
|
+
buffer.Enable(vm)
|
|
208
|
+
|
|
209
|
+
BindCore(vm)
|
|
210
|
+
BindDbx(vm)
|
|
211
|
+
BindSecurity(vm)
|
|
212
|
+
BindOS(vm)
|
|
213
|
+
BindFilepath(vm)
|
|
214
|
+
BindHTTP(vm)
|
|
215
|
+
BindFilesystem(vm)
|
|
216
|
+
BindForms(vm)
|
|
217
|
+
BindMails(vm)
|
|
218
|
+
|
|
219
|
+
vm.Set("$template", templateRegistry)
|
|
220
|
+
vm.Set("__hooks", absHooksDir)
|
|
221
|
+
|
|
222
|
+
vm.Set("migrate", func(up, down func(txApp core.App) error) {
|
|
223
|
+
core.AppMigrations.Register(up, down, file)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if p.config.OnInit != nil {
|
|
227
|
+
p.config.OnInit(vm)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_, err := p.runScript(vm, defaultScriptPath, string(content))
|
|
231
|
+
if err != nil {
|
|
232
|
+
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return nil
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// registerHooks registers the JS app hooks loader.
|
|
240
|
+
func (p *plugin) registerHooks() error {
|
|
241
|
+
// fetch all js hooks sorted by their filename
|
|
242
|
+
files, err := filesContent(p.config.HooksDir, p.config.HooksFilesPattern)
|
|
243
|
+
if err != nil {
|
|
244
|
+
return err
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// prepend the types reference directive
|
|
248
|
+
//
|
|
249
|
+
// note: it is loaded during startup to handle conveniently also
|
|
250
|
+
// the case when the HooksWatch option is enabled and the application
|
|
251
|
+
// restart on newly created file
|
|
252
|
+
for name, content := range files {
|
|
253
|
+
if len(content) != 0 {
|
|
254
|
+
// skip non-empty files for now to prevent accidental overwrite
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
path := filepath.Join(p.config.HooksDir, name)
|
|
258
|
+
directive := `/// <reference path="` + p.relativeTypesPath(p.config.HooksDir) + `" />`
|
|
259
|
+
if err := prependToEmptyFile(path, directive+"\n\n"); err != nil {
|
|
260
|
+
color.Yellow("Unable to prepend the types reference: %v", err)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// initialize the hooks dir watcher
|
|
265
|
+
if p.config.HooksWatch {
|
|
266
|
+
if err := p.watchHooks(); err != nil {
|
|
267
|
+
color.Yellow("Unable to init hooks watcher: %v", err)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if len(files) == 0 {
|
|
272
|
+
// no need to register the vms since there are no entrypoint files anyway
|
|
273
|
+
return nil
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
absHooksDir, err := filepath.Abs(p.config.HooksDir)
|
|
277
|
+
if err != nil {
|
|
278
|
+
return err
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
p.app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
282
|
+
e.Router.BindFunc(p.normalizeServeExceptions)
|
|
283
|
+
|
|
284
|
+
return e.Next()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// safe to be shared across multiple vms
|
|
288
|
+
requireRegistry := new(require.Registry)
|
|
289
|
+
templateRegistry := template.NewRegistry()
|
|
290
|
+
|
|
291
|
+
sharedBinds := func(vm *goja.Runtime) {
|
|
292
|
+
requireRegistry.Enable(vm)
|
|
293
|
+
console.Enable(vm)
|
|
294
|
+
process.Enable(vm)
|
|
295
|
+
buffer.Enable(vm)
|
|
296
|
+
|
|
297
|
+
BindCore(vm)
|
|
298
|
+
BindDbx(vm)
|
|
299
|
+
BindSecurity(vm)
|
|
300
|
+
BindOS(vm)
|
|
301
|
+
BindFilepath(vm)
|
|
302
|
+
BindHTTP(vm)
|
|
303
|
+
BindFilesystem(vm)
|
|
304
|
+
BindForms(vm)
|
|
305
|
+
BindMails(vm)
|
|
306
|
+
BindApis(vm)
|
|
307
|
+
|
|
308
|
+
vm.Set("$app", p.app)
|
|
309
|
+
vm.Set("$template", templateRegistry)
|
|
310
|
+
vm.Set("__hooks", absHooksDir)
|
|
311
|
+
|
|
312
|
+
if p.config.OnInit != nil {
|
|
313
|
+
p.config.OnInit(vm)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// initiliaze the executor vms
|
|
318
|
+
executors := newPool(p.config.HooksPoolSize, func() *goja.Runtime {
|
|
319
|
+
executor := goja.New()
|
|
320
|
+
sharedBinds(executor)
|
|
321
|
+
return executor
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// initialize the loader vm
|
|
325
|
+
loader := goja.New()
|
|
326
|
+
sharedBinds(loader)
|
|
327
|
+
hooksBinds(p.app, loader, executors)
|
|
328
|
+
cronBinds(p.app, loader, executors)
|
|
329
|
+
routerBinds(p.app, loader, executors)
|
|
330
|
+
|
|
331
|
+
for file, content := range files {
|
|
332
|
+
func() {
|
|
333
|
+
defer func() {
|
|
334
|
+
if err := recover(); err != nil {
|
|
335
|
+
fmtErr := fmt.Errorf("failed to execute %s:\n - %v", file, err)
|
|
336
|
+
|
|
337
|
+
if p.config.HooksWatch {
|
|
338
|
+
color.Red("%v", fmtErr)
|
|
339
|
+
} else {
|
|
340
|
+
panic(fmtErr)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}()
|
|
344
|
+
|
|
345
|
+
_, err := p.runScript(loader, defaultScriptPath, string(content))
|
|
346
|
+
if err != nil {
|
|
347
|
+
panic(err)
|
|
348
|
+
}
|
|
349
|
+
}()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return nil
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// normalizeExceptions registers a global error handler that
|
|
356
|
+
// wraps the extracted goja exception error value for consistency
|
|
357
|
+
// when throwing or returning errors.
|
|
358
|
+
func (p *plugin) normalizeServeExceptions(e *core.RequestEvent) error {
|
|
359
|
+
err := e.Next()
|
|
360
|
+
|
|
361
|
+
if err == nil || e.Written() {
|
|
362
|
+
return err // no error or already committed
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return normalizeException(err)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// watchHooks initializes a hooks file watcher that will restart the
|
|
369
|
+
// application (*if possible) in case of a change in the hooks directory.
|
|
370
|
+
//
|
|
371
|
+
// This method does nothing if the hooks directory is missing.
|
|
372
|
+
func (p *plugin) watchHooks() error {
|
|
373
|
+
watchDir := p.config.HooksDir
|
|
374
|
+
|
|
375
|
+
hooksDirInfo, err := os.Lstat(p.config.HooksDir)
|
|
376
|
+
if err != nil {
|
|
377
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
378
|
+
return nil // no hooks dir to watch
|
|
379
|
+
}
|
|
380
|
+
return err
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if hooksDirInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
|
|
384
|
+
watchDir, err = filepath.EvalSymlinks(p.config.HooksDir)
|
|
385
|
+
if err != nil {
|
|
386
|
+
return fmt.Errorf("failed to resolve hooksDir symlink: %w", err)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
watcher, err := fsnotify.NewWatcher()
|
|
391
|
+
if err != nil {
|
|
392
|
+
return err
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
var debounceTimer *time.Timer
|
|
396
|
+
|
|
397
|
+
stopDebounceTimer := func() {
|
|
398
|
+
if debounceTimer != nil {
|
|
399
|
+
debounceTimer.Stop()
|
|
400
|
+
debounceTimer = nil
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
p.app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
|
|
405
|
+
watcher.Close()
|
|
406
|
+
|
|
407
|
+
stopDebounceTimer()
|
|
408
|
+
|
|
409
|
+
return e.Next()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// start listening for events.
|
|
413
|
+
routine.FireAndForget(func() {
|
|
414
|
+
defer stopDebounceTimer()
|
|
415
|
+
|
|
416
|
+
for {
|
|
417
|
+
select {
|
|
418
|
+
case event, ok := <-watcher.Events:
|
|
419
|
+
if !ok {
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
stopDebounceTimer()
|
|
424
|
+
|
|
425
|
+
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
|
|
426
|
+
// app restart is currently not supported on Windows
|
|
427
|
+
if runtime.GOOS == "windows" {
|
|
428
|
+
color.Yellow("File %s changed, please restart the app manually", event.Name)
|
|
429
|
+
} else {
|
|
430
|
+
color.Yellow("File %s changed, restarting...", event.Name)
|
|
431
|
+
if err := p.app.Restart(); err != nil {
|
|
432
|
+
color.Red("Failed to restart the app:", err)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
case err, ok := <-watcher.Errors:
|
|
437
|
+
if !ok {
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
color.Red("Watch error:", err)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// add directories to watch
|
|
446
|
+
//
|
|
447
|
+
// @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18)
|
|
448
|
+
dirsErr := filepath.WalkDir(watchDir, func(path string, entry fs.DirEntry, err error) error {
|
|
449
|
+
// ignore access failures and non-dir entries
|
|
450
|
+
if err != nil || !entry.IsDir() {
|
|
451
|
+
return nil
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// skip traversing hidden directories and node_modules
|
|
455
|
+
if strings.HasPrefix(entry.Name(), ".") || entry.Name() == "node_modules" {
|
|
456
|
+
return filepath.SkipDir
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return watcher.Add(path)
|
|
460
|
+
})
|
|
461
|
+
if dirsErr != nil {
|
|
462
|
+
watcher.Close()
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return dirsErr
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// fullTypesPathReturns returns the full path to the generated TS file.
|
|
469
|
+
func (p *plugin) fullTypesPath() string {
|
|
470
|
+
return filepath.Join(p.config.TypesDir, typesFileName)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// relativeTypesPath returns a path to the generated TS file relative
|
|
474
|
+
// to the specified basepath.
|
|
475
|
+
//
|
|
476
|
+
// It fallbacks to the full path if generating the relative path fails.
|
|
477
|
+
func (p *plugin) relativeTypesPath(basepath string) string {
|
|
478
|
+
fullPath := p.fullTypesPath()
|
|
479
|
+
|
|
480
|
+
rel, err := filepath.Rel(basepath, fullPath)
|
|
481
|
+
if err != nil {
|
|
482
|
+
// fallback to the full path
|
|
483
|
+
rel = fullPath
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return rel
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// refreshTypesFile saves the embedded TS declarations as a file on the disk.
|
|
490
|
+
func (p *plugin) refreshTypesFile() error {
|
|
491
|
+
fullPath := p.fullTypesPath()
|
|
492
|
+
|
|
493
|
+
// ensure that the types directory exists
|
|
494
|
+
dir := filepath.Dir(fullPath)
|
|
495
|
+
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
|
496
|
+
return err
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// retrieve the types data to write
|
|
500
|
+
data, err := generated.Types.ReadFile(typesFileName)
|
|
501
|
+
if err != nil {
|
|
502
|
+
return err
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// read the first timestamp line of the old file (if exists) and compare it to the embedded one
|
|
506
|
+
// (note: ignore errors to allow always overwriting the file if it is invalid)
|
|
507
|
+
existingFile, err := os.Open(fullPath)
|
|
508
|
+
if err == nil {
|
|
509
|
+
timestamp := make([]byte, 13)
|
|
510
|
+
io.ReadFull(existingFile, timestamp)
|
|
511
|
+
existingFile.Close()
|
|
512
|
+
|
|
513
|
+
if len(data) >= len(timestamp) && bytes.Equal(data[:13], timestamp) {
|
|
514
|
+
return nil // nothing new to save
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return os.WriteFile(fullPath, data, 0644)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// prependToEmptyFile prepends the specified text to an empty file.
|
|
522
|
+
//
|
|
523
|
+
// If the file is not empty this method does nothing.
|
|
524
|
+
func prependToEmptyFile(path, text string) error {
|
|
525
|
+
info, err := os.Stat(path)
|
|
526
|
+
|
|
527
|
+
if err == nil && info.Size() == 0 {
|
|
528
|
+
return os.WriteFile(path, []byte(text), 0644)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return err
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// filesContent returns a map with all direct files within the specified dir and their content.
|
|
535
|
+
//
|
|
536
|
+
// If directory with dirPath is missing or no files matching the pattern were found,
|
|
537
|
+
// it returns an empty map and no error.
|
|
538
|
+
//
|
|
539
|
+
// If pattern is empty string it matches all root files.
|
|
540
|
+
func filesContent(dirPath string, pattern string) (map[string][]byte, error) {
|
|
541
|
+
files, err := os.ReadDir(dirPath)
|
|
542
|
+
if err != nil {
|
|
543
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
544
|
+
return map[string][]byte{}, nil
|
|
545
|
+
}
|
|
546
|
+
return nil, err
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
var exp *regexp.Regexp
|
|
550
|
+
if pattern != "" {
|
|
551
|
+
var err error
|
|
552
|
+
if exp, err = regexp.Compile(pattern); err != nil {
|
|
553
|
+
return nil, err
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
result := map[string][]byte{}
|
|
558
|
+
|
|
559
|
+
for _, f := range files {
|
|
560
|
+
if f.IsDir() || (exp != nil && !exp.MatchString(f.Name())) {
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
|
|
565
|
+
if err != nil {
|
|
566
|
+
return nil, err
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
result[f.Name()] = raw
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return result, nil
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
func (p *plugin) runScript(vm *goja.Runtime, name, source string) (goja.Value, error) {
|
|
576
|
+
if p.config.ProgramSource == nil {
|
|
577
|
+
return vm.RunScript(name, source)
|
|
578
|
+
}
|
|
579
|
+
program, err := p.config.ProgramSource(name, source)
|
|
580
|
+
if err != nil {
|
|
581
|
+
return nil, err
|
|
582
|
+
}
|
|
583
|
+
if program == nil {
|
|
584
|
+
return nil, errors.New("ProgramSource returned nil program")
|
|
585
|
+
}
|
|
586
|
+
return vm.RunProgram(program)
|
|
587
|
+
}
|
package/jsvm/mapper.go
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package jsvm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"reflect"
|
|
5
|
+
"strings"
|
|
6
|
+
"unicode"
|
|
7
|
+
|
|
8
|
+
"github.com/dop251/goja"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
var (
|
|
12
|
+
_ goja.FieldNameMapper = (*FieldMapper)(nil)
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// FieldMapper provides custom mapping between Go and JavaScript property names.
|
|
16
|
+
//
|
|
17
|
+
// It is similar to the builtin "uncapFieldNameMapper" but also converts
|
|
18
|
+
// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get").
|
|
19
|
+
type FieldMapper struct {
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// FieldName implements the [FieldNameMapper.FieldName] interface method.
|
|
23
|
+
func (u FieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string {
|
|
24
|
+
return convertGoToJSName(f.Name)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MethodName implements the [FieldNameMapper.MethodName] interface method.
|
|
28
|
+
func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string {
|
|
29
|
+
return convertGoToJSName(m.Name)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var nameExceptions = map[string]string{"OAuth2": "oauth2"}
|
|
33
|
+
|
|
34
|
+
func convertGoToJSName(name string) string {
|
|
35
|
+
if v, ok := nameExceptions[name]; ok {
|
|
36
|
+
return v
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
startUppercase := make([]rune, 0, len(name))
|
|
40
|
+
|
|
41
|
+
for _, c := range name {
|
|
42
|
+
if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) {
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
startUppercase = append(startUppercase, c)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
totalStartUppercase := len(startUppercase)
|
|
50
|
+
|
|
51
|
+
// all uppercase eg. "JSON" -> "json"
|
|
52
|
+
if len(name) == totalStartUppercase {
|
|
53
|
+
return strings.ToLower(name)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// eg. "JSONField" -> "jsonField"
|
|
57
|
+
if totalStartUppercase > 1 {
|
|
58
|
+
return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// eg. "GetField" -> "getField"
|
|
62
|
+
if totalStartUppercase == 1 {
|
|
63
|
+
return strings.ToLower(name[0:1]) + name[1:]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return name
|
|
67
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package jsvm_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"reflect"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"github.com/spink-dev/pocketbase-extension/jsvm"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestFieldMapper(t *testing.T) {
|
|
11
|
+
mapper := jsvm.FieldMapper{}
|
|
12
|
+
|
|
13
|
+
scenarios := []struct {
|
|
14
|
+
name string
|
|
15
|
+
expected string
|
|
16
|
+
}{
|
|
17
|
+
{"", ""},
|
|
18
|
+
{"test", "test"},
|
|
19
|
+
{"Test", "test"},
|
|
20
|
+
{"miXeD", "miXeD"},
|
|
21
|
+
{"MiXeD", "miXeD"},
|
|
22
|
+
{"ResolveRequestAsJSON", "resolveRequestAsJSON"},
|
|
23
|
+
{"Variable_with_underscore", "variable_with_underscore"},
|
|
24
|
+
{"ALLCAPS", "allcaps"},
|
|
25
|
+
{"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"},
|
|
26
|
+
{"OIDCMap", "oidcMap"},
|
|
27
|
+
{"MD5", "md5"},
|
|
28
|
+
{"OAuth2", "oauth2"},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for i, s := range scenarios {
|
|
32
|
+
field := reflect.StructField{Name: s.name}
|
|
33
|
+
if v := mapper.FieldName(nil, field); v != s.expected {
|
|
34
|
+
t.Fatalf("[%d] Expected FieldName %q, got %q", i, s.expected, v)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
method := reflect.Method{Name: s.name}
|
|
38
|
+
if v := mapper.MethodName(nil, method); v != s.expected {
|
|
39
|
+
t.Fatalf("[%d] Expected MethodName %q, got %q", i, s.expected, v)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|