@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.
Files changed (117) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +26 -0
  3. package/FEATURES.md +32 -0
  4. package/LICENSE.md +17 -0
  5. package/MIGRATION.md +49 -0
  6. package/NOTICE.md +5 -0
  7. package/README.md +26 -0
  8. package/adapter.go +31 -0
  9. package/admin/register.go +60 -0
  10. package/admin/register_test.go +37 -0
  11. package/backups/backup_encryption_test.go +82 -0
  12. package/backups/encryption.go +138 -0
  13. package/backups/integration_test.go +51 -0
  14. package/backups/register.go +120 -0
  15. package/backups/restore.go +119 -0
  16. package/backups/s3_test.go +52 -0
  17. package/backups/swap.go +53 -0
  18. package/backups/swap_test.go +75 -0
  19. package/backups/upload.go +52 -0
  20. package/bin/pocketbase-extension.mjs +25 -0
  21. package/cmd/edge/main.go +123 -0
  22. package/cmd/import-fork/main.go +37 -0
  23. package/cmd/loadtest/main.go +61 -0
  24. package/cmd/loadtest/sandbox.go +73 -0
  25. package/cmd/loadtest/sandbox_test.go +20 -0
  26. package/cmd/pocketbase/main.go +78 -0
  27. package/deploy/README.md +148 -0
  28. package/deploy/app/hooks/README.md +2 -0
  29. package/deploy/app/migrations/1789000000_notes.js +16 -0
  30. package/deploy/app/public/README.md +2 -0
  31. package/deploy/compose.secrets.yaml +8 -0
  32. package/deploy/compose.yaml +68 -0
  33. package/deploy/edge.json +13 -0
  34. package/edge/gateway.go +295 -0
  35. package/edge/gateway_test.go +296 -0
  36. package/edge/openapi.json +1 -0
  37. package/edge/policy.go +150 -0
  38. package/features/collection_singleton.go +13 -0
  39. package/features/collection_singleton_test.go +46 -0
  40. package/features/dimensions_test.go +65 -0
  41. package/features/duplicate.go +178 -0
  42. package/features/duplicate_test.go +128 -0
  43. package/features/field_color.go +46 -0
  44. package/features/field_date_only.go +39 -0
  45. package/features/field_json_schema.go +92 -0
  46. package/features/field_scalar_extensions_test.go +66 -0
  47. package/features/files.go +36 -0
  48. package/features/filter_has_any_test.go +81 -0
  49. package/features/generate_test.go +72 -0
  50. package/features/has_any_visibility_test.go +62 -0
  51. package/features/json.go +50 -0
  52. package/features/membership.go +64 -0
  53. package/features/register.go +45 -0
  54. package/features/schema_test.go +58 -0
  55. package/features/ui/main.js +133 -0
  56. package/features/ui/settings.js +31 -0
  57. package/go.mod +54 -0
  58. package/go.sum +159 -0
  59. package/internal/archive/create.go +91 -0
  60. package/internal/archive/create_test.go +125 -0
  61. package/internal/archive/extract.go +99 -0
  62. package/internal/archive/extract_test.go +88 -0
  63. package/jsvm/binds.go +1273 -0
  64. package/jsvm/binds_app_reset_test.go +314 -0
  65. package/jsvm/binds_test.go +1870 -0
  66. package/jsvm/form_data.go +149 -0
  67. package/jsvm/form_data_test.go +225 -0
  68. package/jsvm/internal/types/generated/embed.go +6 -0
  69. package/jsvm/internal/types/generated/types.d.ts +24820 -0
  70. package/jsvm/internal/types/types.go +1408 -0
  71. package/jsvm/jsvm.go +587 -0
  72. package/jsvm/mapper.go +67 -0
  73. package/jsvm/mapper_test.go +42 -0
  74. package/jsvm/pool.go +73 -0
  75. package/jsvm/program_source_test.go +24 -0
  76. package/loadtest/loadtest.go +202 -0
  77. package/loadtest/loadtest_test.go +84 -0
  78. package/localization/README.md +23 -0
  79. package/localization/catalogue.json +483 -0
  80. package/localization/localization.go +94 -0
  81. package/localization/localization_test.go +21 -0
  82. package/mail/register.go +80 -0
  83. package/mail/register_test.go +49 -0
  84. package/mail/resolve.go +91 -0
  85. package/migration/import.go +96 -0
  86. package/migration/import_test.go +58 -0
  87. package/otp/otp.go +56 -0
  88. package/otp/otp_test.go +56 -0
  89. package/package.json +51 -0
  90. package/scripts/check-edge.py +42 -0
  91. package/scripts/check.sh +11 -0
  92. package/scripts/sync-jsvm-types.sh +10 -0
  93. package/security/README.md +94 -0
  94. package/security/assurance_test.go +149 -0
  95. package/security/compatibility_test.go +128 -0
  96. package/security/config.go +78 -0
  97. package/security/dashboard_test.go +103 -0
  98. package/security/management.go +169 -0
  99. package/security/openapi.json +508 -0
  100. package/security/review.go +34 -0
  101. package/security/security.go +503 -0
  102. package/security/security_test.go +146 -0
  103. package/security/state.go +116 -0
  104. package/security/ui/dashboard.css +4 -0
  105. package/security/ui/dashboard.js +83 -0
  106. package/security/ui/main.js +15 -0
  107. package/security/ui/model.js +32 -0
  108. package/security/ui/model.test.mjs +25 -0
  109. package/security/ui/registration.test.mjs +10 -0
  110. package/settings/env_test.go +41 -0
  111. package/settings/openapi.json +193 -0
  112. package/settings/settings.go +155 -0
  113. package/settings/settings_test.go +31 -0
  114. package/watcher/watcher.go +192 -0
  115. package/watcher/watcher_test.go +200 -0
  116. package/web/static.go +99 -0
  117. 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
+ }