PBScript integration

This commit is contained in:
Ben Allfree 2022-10-28 05:12:27 -07:00
parent a3c522fca3
commit a0fe228c81
58 changed files with 3902 additions and 0 deletions

1
packages/cli/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist

3
packages/cli/.npmignore Normal file
View File

@ -0,0 +1,3 @@
src
tsconfig.json
dist/index.*

58
packages/cli/package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "pockethost",
"version": "0.0.5",
"main": "./dist/index.js",
"bin": {
"pbscript": "./dist/pbscript"
},
"author": {
"name": "Ben Allfree",
"url": "https://github.com/benallfree"
},
"repository": {
"url": "https://github.com/benallfree/pockethost"
},
"scripts": {
"dev": "chokidar package.json 'src/**' './node_modules/**' -c 'yarn build' --initial",
"build": "parcel build --no-cache && yarn build:shebang && npm i -g --force .",
"build:shebang": "echo \"#!/usr/bin/env node\"|cat - ./dist/index.js > ./dist/pbscript",
"publish": "npm publish --access public"
},
"license": "MIT",
"devDependencies": {
"parcel": "^2.7.0"
},
"dependencies": {
"@types/eventsource": "^1.1.9",
"@types/node": "^18.8.2",
"@types/prompts": "^2.4.1",
"@types/tmp": "^0.2.3",
"commander": "^9.4.0",
"cross-fetch": "^3.1.5",
"eventsource": "^2.0.2",
"find-up": "^6.3.0",
"pocketbase": "^0.7.1",
"prompts": "^2.4.2",
"tmp": "^0.2.1"
},
"targets": {
"node": {
"engines": {
"node": ">= 12"
},
"source": "./src/index.ts",
"context": "node",
"outputFormat": "commonjs",
"includeNodeModules": [
"@s-libs/micro-dash",
"find-up",
"locate-path",
"path-exists",
"p-locate",
"p-limit",
"yocto-queue",
"pocketbase"
]
}
}
}

25
packages/cli/readme.md Normal file
View File

@ -0,0 +1,25 @@
# TS/JS Cloud Functions for PocketBase
[PBScript](https://github.com/benallfree/pbscript) allows you to write [PocketBase](https://pocketbase.io) server-side functions in Typescript or Javascript without recompiling.
This package is the CLI tool.
```bash
npm i -g pbscript
```
or
```bash
npx pbscript --help
```
or
```bash
npm i -D pbscript
```
And then reference from `package.json`.
See official docs at the [PBScript repo](https://github.com/benallfree/pbscript).

View File

@ -0,0 +1,90 @@
import { Parcel } from '@parcel/core'
import { debounce } from '@s-libs/micro-dash'
import chokidar from 'chokidar'
import { Command } from 'commander'
import { mkdirSync } from 'fs'
import { join } from 'path'
import { cwd } from 'process'
import { getProjectRoot, readSettings } from '../util/project'
export type BuildConfig = {
dist: string
src: string
}
export const addDevCommand = (program: Command) => {
program
.command('build')
.description('Build the JS bundle')
.option(
'--src <path>',
`Path to source (default: <project>/src/index.{ts|js})`
)
.option('--dist <path>', `Path to dist (default: <project>/dist/index.js)`)
.action(async (options) => {
const defaultSrc = options.src
const defaultDist = options.dist
const config: BuildConfig = {
src: join(getProjectRoot(), './src/index.ts'),
dist: join(getProjectRoot(), './dist/index.js'),
...readSettings('build'),
}
if (defaultDist) config.dist = defaultDist
if (defaultSrc) config.src = defaultSrc
const { src, dist } = config
mkdirSync(dist, { recursive: true })
console.log(cwd())
const bundler = new Parcel({
entries: './src/index.ts',
defaultConfig: '@parcel/config-default',
mode: 'production',
defaultTargetOptions: {
distDir: join(cwd(), 'dist'),
outputFormat: 'global',
sourceMaps: false,
},
shouldDisableCache: true,
// targets: {
// iife: {
// distDir: './dist',
// source: './src/index.ts',
// context: 'browser',
// outputFormat: 'global',
// sourceMap: {
// inline: true,
// },
// },
// },
})
const build = debounce(() => {
console.log(`Building...`)
bundler
.run()
.then((e) => {
console.log(`Build succeeded`, e)
console.log(e.bundleGraph.getBundles({ includeInline: true }))
let { bundleGraph } = e
})
.catch((e) => {
console.error(`Build failed with ${e}`)
})
}, 100)
build()
console.log(join(getProjectRoot(), 'src/**'))
chokidar
.watch([
join(getProjectRoot(), 'src/**'),
join(getProjectRoot(), 'node_modules/**'),
])
.on('all', (event, path) => {
build()
})
console.log('here')
})
}

View File

@ -0,0 +1,99 @@
import { Parcel } from '@parcel/core'
import { debounce } from '@s-libs/micro-dash'
import chokidar from 'chokidar'
import { Command } from 'commander'
import { join } from 'path'
import { cwd } from 'process'
import tmp from 'tmp'
import { DEFAULT_PB_DEV_URL } from '../constants'
import { SessionState } from '../providers/CustomAuthStore'
import { ensureAdminClient } from '../util/ensureAdminClient'
import { getProjectRoot, readSettings } from '../util/project'
export type DevConfig = {
session: SessionState
host: string
src: string
dist: string
}
export const addDevCommand = (program: Command) => {
program
.command('dev')
.description('Watch for source code changes in development mode')
.option(
'--src <path>',
`Path to source (default: <project>/src/index.{ts|js})`
)
.option('--dist <path>', `Path to dist (default: <project>/dist/index.js)`)
.option('--host', 'PocketBase host', DEFAULT_PB_DEV_URL)
.action(async (options) => {
const defaultSrc = options.src
const defaultDist = options.dist
const defaultHost = options.host
const config: DevConfig = {
session: { token: '', model: null },
host: DEFAULT_PB_DEV_URL,
src: join(getProjectRoot(), './src/index.ts'),
dist: join(getProjectRoot(), './dist/index.js'),
...readSettings('dev'),
}
if (defaultDist) config.dist = defaultDist
if (defaultSrc) config.src = defaultSrc
const client = await ensureAdminClient('dev', config)
const distDir = tmp.dirSync().name
console.log(cwd())
const bundler = new Parcel({
entries: './src/index.ts',
defaultConfig: '@parcel/config-default',
mode: 'production',
defaultTargetOptions: {
distDir: join(cwd(), 'dist'),
outputFormat: 'global',
sourceMaps: false,
},
shouldDisableCache: true,
// targets: {
// iife: {
// distDir: './dist',
// source: './src/index.ts',
// context: 'browser',
// outputFormat: 'global',
// sourceMap: {
// inline: true,
// },
// },
// },
})
const build = debounce(() => {
console.log(`Building...`)
bundler
.run()
.then((e) => {
console.log(`Build succeeded`, e)
console.log(e.bundleGraph.getBundles({ includeInline: true }))
let { bundleGraph } = e
})
.catch((e) => {
console.error(`Build failed with ${e}`)
})
}, 100)
build()
console.log(join(getProjectRoot(), 'src/**'))
chokidar
.watch([
join(getProjectRoot(), 'src/**'),
join(getProjectRoot(), 'node_modules/**'),
])
.on('all', (event, path) => {
build()
})
console.log('here')
})
}

View File

@ -0,0 +1,100 @@
import { Command } from 'commander'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import pocketbaseEs from 'pocketbase'
import { DEFAULT_PB_DEV_URL } from '../constants'
import { SessionState } from '../providers/CustomAuthStore'
import { die } from '../util/die'
import { ensureAdminClient } from '../util/ensureAdminClient'
import { getProjectRoot, readSettings } from '../util/project'
export type PublishConfig = {
session: SessionState
host: string
dist: string
}
const migrate = async (client: pocketbaseEs) => {
{
// VERSION 1
const res = await client.collections.getList(1, 1, {
filter: `name='pbscript'`,
})
const [item] = res.items
if (!item) {
await client.collections.create({
name: 'pbscript',
schema: [
{
name: 'type',
type: 'text',
required: true,
},
{
name: 'isActive',
type: 'bool',
},
{
name: 'data',
type: 'json',
required: true,
},
],
})
}
}
}
const publish = async (client: pocketbaseEs, fname: string) => {
const js = readFileSync(fname).toString()
const url = `${client.baseUrl}/api/pbscript/deploy`
const res = await client
.send(`api/pbscript/deploy`, {
method: 'post',
body: JSON.stringify({
source: js,
}),
headers: { 'Content-Type': 'application/json' },
})
.catch((e) => {
console.error(e)
throw e
})
}
export const addPublishCommand = (program: Command) => {
const _srcDefault = join(getProjectRoot(), './dist/index.js')
const _hostDefault = DEFAULT_PB_DEV_URL
program
.command('publish')
.description('Publish JS bundle to PBScript-enabled PocketBase instance')
.option(
'--dist <src>',
`Path to dist bundle (default: <project>/dist/index.js)`
)
.option('--host <host>', `PocketBase host (default: ${DEFAULT_PB_DEV_URL})`)
.action(async (options) => {
const defaultHost = options.host
const defaultDist = options.dist
const config: PublishConfig = {
session: { token: '', model: null },
host: DEFAULT_PB_DEV_URL,
dist: join(getProjectRoot(), './dist/index.js'),
...readSettings<PublishConfig>('publish'),
}
if (defaultHost) config.host = defaultHost
if (defaultDist) config.dist = defaultDist
const { host, dist } = config
if (!existsSync(dist)) {
die(`${dist} does not exist. Nothing to publish.`)
}
const client = await ensureAdminClient('publish', config)
console.log(`Deploying from ${dist} to ${host}`)
await migrate(client)
await publish(client, dist)
})
}

View File

@ -0,0 +1 @@
export const DEFAULT_PB_DEV_URL = `http://127.0.0.1:8090`

View File

@ -0,0 +1,38 @@
import { client } from '../client'
import {
Pb_Any_Record_Db,
Pb_CollectionName,
Pb_PkId,
Pb_Untrusted_Db,
} from '../schema/base'
export const createRealtimeSubscriptionManager = () => {
const subscriptions: { [_: string]: number } = {}
const subscribe = <TRec extends Pb_Any_Record_Db>(
collectionName: Pb_CollectionName,
cb: (rec: Pb_Untrusted_Db<TRec>) => void,
id?: Pb_PkId
) => {
const slug = id ? `${collectionName}/${id}` : collectionName
if (subscriptions[slug]) {
subscriptions[slug]++
} else {
subscriptions[slug] = 1
client.realtime.subscribe(slug, (e) => {
console.log(`Realtime update`, { e })
cb(e.record as unknown as Pb_Untrusted_Db<TRec>)
})
}
return () => {
subscriptions[slug]--
if (subscriptions[slug] === 0) {
console.log(`Realtime unsub`)
client.realtime.unsubscribe(slug)
}
}
}
return subscribe
}

View File

@ -0,0 +1,13 @@
import { map } from '@s-libs/micro-dash'
import { Pb_Any_Record_Db, Pb_QueryParams } from '../schema/base'
export type FieldStruct<TRec extends Pb_Any_Record_Db> = Partial<{
[_ in keyof TRec]: TRec[_]
}>
export const buildQueryFilter = <TRec extends Pb_Any_Record_Db>(
fields: FieldStruct<TRec>
): Pb_QueryParams => {
const filter = map(fields, (v, k) => `${k.toString()} = "${v}"`).join(' and ')
return { filter }
}

View File

@ -0,0 +1,22 @@
import { assertTruthy } from 'util/assert'
import { client } from '../client'
import {
Pb_Any_Collection_Name,
Pb_Any_Record_Db,
Pb_Untrusted_Db,
} from '../schema/base'
import { buildQueryFilter, FieldStruct } from './buildQueryFilter'
export const getOne = async <
TRec extends Pb_Any_Record_Db,
TFields extends FieldStruct<TRec> = FieldStruct<TRec>
>(
collectionName: Pb_Any_Collection_Name,
fields: TFields
) => {
const queryParams = buildQueryFilter(fields)
const recs = await client.records.getList(collectionName, 1, 2, queryParams)
assertTruthy(recs.totalItems < 2, `Expected exactly 0 or 1 items here`)
const [item] = recs.items
return item ? (recs.items[0] as unknown as Pb_Untrusted_Db<TRec>) : null
}

View File

@ -0,0 +1,6 @@
export * from './getOne'
export * from './onAuthStateChanged'
export * from './pbUid'
export * from './RealtimeSubscriptionManager'
export * from './signInAnonymously'
export * from './upsert'

View File

@ -0,0 +1,27 @@
import { forEach, isObject } from '@s-libs/micro-dash'
export const mergeDeep = <TObject>(dst: any, src: TObject) => {
forEach(src, (v, k) => {
if (isObject(v)) {
if (dst[k] === undefined) dst[k] = {}
if (!isObject(dst[k])) {
throw new Error(
`${k.toString()} is an object in default, but not in target`
)
}
dst[k] = mergeDeep(dst[k], v)
} else {
if (isObject(dst[k])) {
throw new Error(
`${k.toString()} is an object in target, but not in default`
)
}
// The magic: if the target has no value for this field, use the
// default value
if (dst[k] === undefined) {
dst[k] = v
}
}
})
return dst as TObject
}

View File

@ -0,0 +1,11 @@
import { UnsubFunc } from 'store/backend/types'
import { client } from '../client'
export const onAuthStateChanged = (
cb: (user: typeof client.authStore.model) => void
): UnsubFunc => {
setTimeout(() => cb(client.authStore.model), 0)
return client.authStore.onChange(() => {
cb(client.authStore.model)
})
}

View File

@ -0,0 +1,9 @@
import { identity } from 'ts-brand'
import { client } from '../client'
import { Pb_UserId } from '../schema/base'
export const pbUid = () => {
const { id } = client.authStore.model || {}
if (!id) return
return identity<Pb_UserId>(id)
}

View File

@ -0,0 +1,22 @@
import { customAlphabet } from 'nanoid'
import { identity } from 'ts-brand'
import { client } from '../client'
import { Email, Password, PbCreds } from '../schema/base'
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz')
export const signInAnonymously = () => {
const { email, password } = (() => {
const credsJson = localStorage.getItem('__pb_creds')
if (credsJson) {
return JSON.parse(credsJson) as PbCreds
}
const email = identity<Email>(`${nanoid()}@harvest.io`)
const password = identity<Password>(nanoid())
return { email, password }
})()
return client.users.authViaEmail(email, password).catch((e) => {
console.error(`Couldn't long in anonymously: ${e}`)
})
}

View File

@ -0,0 +1,49 @@
import { reduce } from '@s-libs/micro-dash'
import produce, { Draft, produceWithPatches } from 'immer'
import { assertExists, assertTruthy } from 'util/assert'
import { client } from '../client'
import {
Pb_Any_Collection_Name,
Pb_Any_Record_Db,
Pb_Untrusted_Db,
Pb_UserFields,
} from '../schema/base'
import { buildQueryFilter, FieldStruct } from './buildQueryFilter'
import { mergeDeep } from './mergeDeep'
export const upsert = async <TRow extends Pb_Any_Record_Db>(
collectionName: Pb_Any_Collection_Name,
filterFields: FieldStruct<TRow>,
mutate: (draft: Draft<Pb_UserFields<TRow>>) => void,
defaultRec: Pb_UserFields<TRow>
) => {
const queryParams = buildQueryFilter(filterFields)
const recs = await client.records.getList(collectionName, 1, 2, queryParams)
assertTruthy(recs.totalItems < 2, `Expected exactly 0 or 1 item to upsert`)
if (recs.totalItems === 0) {
// Insert
client.records.create(collectionName, produce(defaultRec, mutate))
} else {
// Update
const [item] = recs.items as unknown as Pb_Untrusted_Db<TRow>[]
assertExists(item, `Expected item here`)
const { id } = item
const safeItem = mergeDeep(item, defaultRec)
const [rec, patches] = produceWithPatches(safeItem, mutate)
console.log({ patches })
const final = reduce(
patches,
(carry, patch) => {
if (patch.op === 'add' || patch.op === 'replace') {
const [key] = patch.path
assertExists(key, `Expected key here`)
const _key = `${key}` as keyof Pb_UserFields<TRow>
carry[_key] = rec[_key]
}
return carry
},
{} as Partial<Pb_UserFields<TRow>>
)
client.records.update(collectionName, id, final)
}
}

20
packages/cli/src/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { program } from 'commander'
declare global {
let __go_app: any
}
import 'cross-fetch/polyfill'
import 'eventsource'
import packagex from '../package.json'
import { addPublishCommand } from './commands/publish'
console.log(`PBScript ${packagex.version}`)
program
.name('pbscript')
.description('CLI for JavaScript extensions for PocketBase ')
.version('0.0.1')
addPublishCommand(program)
// addDevCommand(program)
program.parse()

View File

@ -0,0 +1,57 @@
import { Admin, BaseAuthStore, User } from 'pocketbase'
export interface SerializeOptions {
encode?: (val: string | number | boolean) => string
maxAge?: number
domain?: string
path?: string
expires?: Date
httpOnly?: boolean
secure?: boolean
priority?: string
sameSite?: boolean | string
}
export type SessionState = { token: string; model: User | Admin | null }
export type SessionStateSaver = (state: SessionState) => void
export class CustomAuthStore extends BaseAuthStore {
_save: SessionStateSaver
constructor(state: SessionState, _save: SessionStateSaver) {
super()
const { token, model } = state
this.baseToken = token
this.baseModel = model
this._save = _save
}
get get() {
// console.log(`Get token`)
return this.baseToken
}
get model(): User | Admin | null {
// console.log(`get model`)
return this.baseModel
}
get isValid(): boolean {
// console.log(`isValid`)
return !!this.baseToken
}
save(token: string, model: User | Admin | null) {
this._save({ token, model })
this.baseModel = model
this.baseToken = token
}
clear(): void {
throw new Error(`Unsupported clear()`)
}
loadFromCookie(cookie: string, key?: string | undefined): void {
throw new Error(`Unsupported loadFromCookie()`)
}
exportToCookie(
options?: SerializeOptions | undefined,
key?: string | undefined
): string {
throw new Error(`Unsupported exportToCookie()`)
}
}

View File

@ -0,0 +1,8 @@
export function assertExists<TType>(
v: TType,
message = `Value does not exist`
): asserts v is NonNullable<TType> {
if (typeof v === 'undefined') {
throw new Error(message)
}
}

View File

@ -0,0 +1,4 @@
export function die(msg: string): never {
console.error(msg)
process.exit(1)
}

View File

@ -0,0 +1,71 @@
import prompts from 'prompts'
import { SessionState } from '../providers/CustomAuthStore'
import { die } from './die'
import { isAdmin, pbClient } from './pbClient'
import { mkProjectSaver } from './project'
function isEmail(email: string) {
var emailFormat = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/
if (email !== '' && email.match(emailFormat)) {
return true
}
return false
}
export type ConnectionConfig = {
session: SessionState
host: string
}
export const ensureAdminClient = async (
slug: string,
config: ConnectionConfig
) => {
const saver = mkProjectSaver<ConnectionConfig>(slug)
const client = pbClient(config, (session) =>
saver((config) => ({ ...config, session }))
)
const _isAdmin = await isAdmin(client)
if (_isAdmin) {
return client
}
const { host } = config
console.log(
`You must be logged in to ${host}/_ as a PocketBase admin to continue.`
)
while (true) {
const response = await prompts(
[
{
type: 'text',
name: 'username',
message: 'Username (email):',
validate: (value: string) =>
isEmail(value) ? true : `Enter an email address`,
},
{
type: 'password',
name: 'password',
message: 'Password:',
validate: (value: string) =>
value.length > 0 ? true : `Enter a password`,
},
],
{ onCancel: () => die(`Exited.`) }
)
const { username, password } = response
try {
await client.admins.authViaEmail(username, password)
saver((config) => ({ ...config, host }))
console.log(`Successfully logged in as ${username} for project `)
break
} catch (e) {
console.error(`Login failed for ${username}. Try again.`)
}
}
return client
}

View File

@ -0,0 +1,47 @@
import { default as PocketBase, default as pocketbaseEs } from 'pocketbase'
import {
CustomAuthStore,
SessionStateSaver,
} from '../providers/CustomAuthStore'
import { assertExists } from './assert'
import { die } from './die'
import { ConnectionConfig } from './ensureAdminClient'
export const pbClient = (
config: ConnectionConfig,
saver: SessionStateSaver
) => {
const { host, session } = config
const client = new PocketBase(
host,
'en-US',
new CustomAuthStore(session, saver)
)
return client
}
export const isAdmin = async (client: pocketbaseEs) => {
if (!client.authStore.isValid) return false
const { model } = client.authStore
if (!model) return false
const res = await client.admins.getOne(model.id)
if (!res) return false
return true
}
export const adminPbClient = async (
config: ConnectionConfig,
saver: SessionStateSaver
) => {
const client = pbClient(config, saver)
if (!client.authStore.isValid) {
die(`Must be logged in to PocketBase as an admin.`)
}
const { model } = client.authStore
assertExists(model, `Expected a valid model here`)
const res = await client.admins.getOne(model.id)
if (!res) {
die(`User must be an admin user.`)
}
return client
}

View File

@ -0,0 +1,52 @@
import { findUpSync } from 'find-up'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { cwd } from 'process'
import { SessionState } from '../providers/CustomAuthStore'
export type TConfigProjectConfig = {
dev: {
session: SessionState
host: string
}
publish: {
session: SessionState
host: string
}
src: string
dist: string
}
export const CACHE_FNAME = '.pbcache'
export const mkProjectSaver = <TConfig>(slug: string) => {
type ConfigMutator = (config: Partial<TConfig>) => Partial<TConfig>
return (m: ConfigMutator) => {
const root = getProjectRoot()
const cachePath = join(root, CACHE_FNAME)
if (!existsSync(cachePath)) {
mkdirSync(cachePath, { recursive: true })
}
const currentConfig = readSettings<TConfig>(slug)
const nextConfig = m(currentConfig)
const fname = join(cachePath, slug)
const json = JSON.stringify(nextConfig, null, 2)
console.log(`Saving to ${fname}`, json)
writeFileSync(`${fname}.json`, json)
}
}
export const getProjectRoot = () => {
const root = findUpSync(`package.json`)
if (!root) return cwd()
return dirname(root)
}
export const readSettings = <TConfig>(name: string): Partial<TConfig> => {
const root = getProjectRoot()
const fname = join(root, CACHE_FNAME, `${name}.json`)
if (!existsSync(fname)) return {}
const json = readFileSync(fname).toString()
const settings = JSON.parse(json)
return settings as Partial<TConfig>
}

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "node",
"noUncheckedIndexedAccess": true,
"strictNullChecks": true
},
"include": ["./src"]
}

1
packages/js-cloud-funcs/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist

View File

@ -0,0 +1,2 @@
src
tsconfig.json

View File

@ -0,0 +1,40 @@
{
"name": "@pockethost/cloud-functions",
"version": "0.0.3",
"source": "src/index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"license": "MIT",
"author": {
"name": "Ben Allfree",
"url": "https://github.com/benallfree"
},
"repository": {
"url": "https://github.com/benallfree/pockethost"
},
"scripts": {
"dev": "chokidar '**' --ignore 'dist' -c 'parcel build --no-cache' --initial",
"build": "parcel build --no-cache"
},
"devDependencies": {
"parcel": "^2.7.0",
"typescript": "^4.8.3"
},
"targets": {
"main": {
"isLibrary": true,
"context": "browser",
"outputFormat": "esmodule",
"sourceMap": true,
"includeNodeModules": true
}
},
"dependencies": {
"@s-libs/micro-dash": "^14.1.0",
"nanoevents": "^7.0.1",
"nanoid": "^4.0.0",
"svelte": "^3.51.0",
"ts-brand": "^0.0.2"
}
}

View File

@ -0,0 +1,282 @@
# TS/JS Cloud Functions for PocketBase
**_Write all your PocketBase server-side logic in TS/JS_**
PBScript allows you to write [PocketBase](https://pocketbase.io) server-side functions in Typescript or Javascript without recompiling.
With PBScript, you can:
- ✅ Write your server-side logic in Typescript or JS
- ✅ Access models, collections, transactions, hooks, and all server-side features of PocketBase
- ✅ Communicate with PocketBase using a streamlined JavaScript-style API
- ✅ Deploy and alter cloud functions without rebuilding _or even restarting_ PocketBase
- ✅ Roll back to previous versions
- ✅ Use the `pbscript` CLI tool for intelligent dev mode, live deployment, and rollback
<h3>Table of Contents</h3>
<!-- @import "[TOC]" {cmd="toc" depthFrom=2 depthTo=6 orderedList=false} -->
<!-- code_chunk_output -->
- [Introduction](#introduction)
- [Getting Started](#getting-started)
- [Developing your script](#developing-your-script)
- [Building and deploying your script](#building-and-deploying-your-script)
- [API](#api)
- [`__go.ping()`](#__goping)
- [`__go.addRoute()`](#__goaddroute)
- [`__go.app`](#__goapp)
- [Advanced](#advanced)
- [Upgrading an Existing Custom PocketBase](#upgrading-an-existing-custom-pocketbase)
<!-- /code_chunk_output -->
## Introduction
PBScript extends PocketBase with an [ES-5.1 (ECMA 262)](https://262.ecma-international.org/5.1/) scripting engine powered by [goja](https://github.com/dop251/goja).
Code executes in a secure sandboxed environment without a [Node.js API](https://nodejs.org/docs/latest/api/) or [Web APIs](https://developer.mozilla.org/en-US/docs/Web/API). Instead, the runtime environment is PocketBase-specific, with full access to PocketBase's [native Go extension API](https://pocketbase.io/docs/use-as-framework/) and includes streamlined functions and helper utilities written in JavaScript (@pbscript/core).
Use `pbscript` command line tool to build and publish cloud functions to any PBScript-enabled PocketBase instance.
## Getting Started
<h3>1. Create a PocketBase instance</h3>
To run JS functions, you need to run a PocketBase instance which has been enhanced with PBScript.
You can do it any way you like, just as long as you end up with an admin login for the next section.
**Option 1 (free): run fully managed on [pockethost.io](https://pockethost.io)**
The absolute easiest way to provision a new PocketBase instance enhanced with PBScript is to use [pockethost.io](https://pockethost.io). You'll be up and running with a PocketBase URL in under 30 seconds. This is as close to a Firebase/Supabase BaaS experience as you can get.
**Option 2 (free): run self-managed on fly.io**
If you'd rather manage your resources yourself, you can follow the instructions in [this thread](https://github.com/pocketbase/pocketbase/discussions/537) to get up and running on fly.io.
This option takes about 30 minutes to set up.
**Option 3 (free): run a custom build locally**
If you just want to run locally or have a special use case, you can create your own build.
Create `pocketbase.go`:
```go
package main
import (
"log"
pocketbase "github.com/benallfree/pbscript/modules/pbscript"
)
func main() {
app := pocketbase.New()
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
On the command line, run:
```bash
go get github.com/benallfree/pbscript/modules/pbscript
go run pocketbase.go serve
> Server started at: http://127.0.0.1:8090
- REST API: http://127.0.0.1:8090/api/
- Admin UI: http://127.0.0.1:8090/_/
```
<h3>2. Create a new JS/TS project</h3>
You can create any type of TS/JS project you want, but here's the `package.json` we recommend. We also have a [sample PBScript project](https://github.com/benallfree/pbscript/tree/master/packages/sample) you can check out.
The important part is that your script gets bundled as ES5:
```json
{
"name": "sample",
"version": "0.0.1",
"dependencies": {
"@pbscript/core": "^0.0.1"
},
"scripts": {
"build": "parcel build --no-cache",
"deploy:local": "pbscript deploy --host 'http://127.0.0.1:8090'",
"dev": "chokidar 'src/**' './node_modules/**' -c 'yarn build && yarn deploy:local' --initial"
},
"targets": {
"iife": {
"source": "./src/index.ts",
"context": "browser",
"outputFormat": "global",
"sourceMap": {
"inline": true
}
}
},
"devDependencies": {
"chokidar-cli": "^3.0.0",
"parcel": "^2.7.0"
}
}
```
<h3>3. Use the `pbscript` CLI tool to log in</h3>
Enter your project directory and log in to your PocketBase instance using the admin account you created in Step #1:
```
npx pbscript login <username> <password> --host <pocketbase URL>
```
This will create a file named `.pbcache`. Add it to `.gitignore`.
## Developing your script
In your command shell, run:
```bash
npx pbscript dev
```
PBScript is designed for a fast development cycle. If you used our `package.json`, this command will watch for changes (in `./dist`) and re-deploy your script on every change.
PBScript updates do not require a PocketBase restart. Old versions of your script are kept in an archive table for easy rollbacks.
## Building and deploying your script
`pbscript` knows how to deploy to any PBScript-enabled PocketBase instance.
**Connect to your live site**
First, connect to your live site. `pbscript` will remember your credentials for future commands.
```bash
npx pbscript login <username> <password> --host https://yourproject.pockethost.io
Saved to .pbcache
```
**Build your `./dist/index.js` bundle**
You can build your script bundle however you want, just make sure you end up with ONE bundle file in `./dist/index.js`. If you use source maps, inline them. `pbscript` only deploys this one file.
**Deploy your latest changes**
You can deploy changes at any time without restarting PocketBase. All realtime connections and users will remain connected.
```bash
pbscript deploy --host <your pocketbase URL>
```
Or, add it to `package.json`:
```json
"scripts": {
"deploy": "pbscript deploy --host https://yourproject.pockethost.io"
}
```
## API
PBScript runs in a secure sandboxed environment inside PocketBase. A simplified subset of PocketBase's hooks are available. Complete Typescript definitions are included.
You might be accustomed to using the [NodeJS API](https://nodejs.org/docs/latest/api/) or the browser [Web API](https://developer.mozilla.org/en-US/docs/Web/API), but those APIs are not core features of ECMAScript. They are not safe or allowed in the PocketBase execution environment.
Instead, your script runs in the `PocketBaseApi` execution environment. `PocketBaseApi` set of API calls to interact with PocketBase. With it, you can do CRUD, transactions, hook into PocketBase events, new API endpoints, and generally extend PocketBase.
PBScript imports a `__go` variable containing low-level access to the PocketBase native API and other helpers implemented in Go. @pbscript/core uses `__go` internally, but if there is something missing, you may be able to accomplish it yourself by accessing `__go` directly.
### `__go.ping()`
Test function that should return `Hello from Go!`
Example:
```ts
console.log(__go.ping())
```
### `__go.addRoute()`
Add an API route.
Example:
```ts
__go.addRoute({
method: HttpMethods.Get,
path: `/api/hello`
handler: (context)=>{
context.send(HttpStatus.Ok, `Hello back!`)
}
})
```
### `__go.app`
Low-level primitive providing direct access to the `PocketBase` app instance. Normally you will not access this directly. The @pbscript/core library is built on top of this.
## Advanced
### Upgrading an Existing Custom PocketBase
The easiest way to get PBScript is to use our custom PocketBase module [github.com/benallfree/pbscript/modules/pocketbase](https://github.com/benallfree/pbscript/modules/pocketbase):
```go
package main
import (
"log"
"github.com/benallfree/pbscript/packages/pocketbase" // Notice this is a custom version of the PocketBase module
)
func main() {
app := pocketbase.New()
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
If you are already using a custom PocketBase build, just swap out `github.com/pocketbase/pocketbase` with `github.com/benallfree/pbscript/packages/pocketbase` and everything will work as expected.
Or, if you prefer, you can do exactly what our custom module does:
```go
package main
import (
"github.com/pocketbase/pocketbase"
"github.com/benallfree/pbscript/packages/pbscript"
)
func main() {
app := pocketbase.New()
pbscript.StartPBScript(app) // Magic line to enable PBScript
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
```
## Changelog
### 0.0.2
- Transaction support
### 0.0.1
- Initial release

View File

@ -0,0 +1,38 @@
import { PackedData } from './index'
import { assertExists } from './util/assert'
import { isFunction } from './util/isFunction'
// JSON encoder to tokenize functions and store their references
export type FunctionToken = string
export const createFunctionMarshaler = () => {
// Create a unique ID for this instance
const nanoid = (() => {
let i = 0
return () => i++
})()
const funcCache: { [_: FunctionToken]: () => any } = {}
const encode = (key: string, value: any) => {
if (isFunction(value)) {
const uuid = `fn_${nanoid()}`
funcCache[uuid] = value
return uuid
}
if (value === undefined) {
return `ü`
}
return value
}
const exec = (tok: FunctionToken) => {
const fn = funcCache[tok]
assertExists(fn, `Function ${tok} does not exist`)
const ret = fn()
return pack(ret)
}
const pack = (o: any) => JSON.stringify(o, encode) as PackedData
return { pack, exec }
}

View File

@ -0,0 +1,42 @@
import { emitter, NativePocketBaseEvents } from './index'
export const CoreMiddleware = {
requireAdminOrUserAuth: () => 'RequireAdminOrUserAuth',
}
export type JsMiddlewareToken = string
export type JsHttpRoutePath = string
export type JsAddRouteConfig = {
method: HttpMethods
path: JsHttpRoutePath
handler: (context: HttpRequestContext) => void
middlewares: JsMiddlewareToken[]
}
export type OnBeforeServeEvent = {
Router: {
addRoute: (config: JsAddRouteConfig) => void
}
}
export const onBeforeServe = (cb: (e: OnBeforeServeEvent) => void) => {
emitter.on(NativePocketBaseEvents.OnBeforeServe, cb)
}
export const dispatchObBeforeServe = ()=>{
case NativePocketBaseEvents.OnBeforeServe:
const e: OnBeforeServeEvent = {
Router: {
addRoute: (config) => {
const packed = pack(config)
console.log(`Sending config back ${packed}`)
__go_addRoute(packed)
},
},
}
emitter.emit(eventName, e)
}

View File

@ -0,0 +1,7 @@
export enum HttpMethods {
Get = 'GET',
}
export enum HttpResponseStatuses {
Ok = 200,
}

View File

@ -0,0 +1,616 @@
import { createEvent } from './util/event'
export type ModelBeforeCreateEvent = {}
const [onModelBeforeCreate, fireModelBeforeCreate] =
createEvent<ModelBeforeCreateEvent>(`OnModelBeforeCreate`)
export { onModelBeforeCreate, fireModelBeforeCreate }
export { onModelAfterCreate, fireModelAfterCreate }
export { onModelBeforeUpdate, fireModelBeforeUpdate }
export { onModelAfterUpdate, fireModelAfterUpdate }
export { onModelBeforeDelete, fireModelBeforeDelete }
export { onModelAfterDelete, fireModelAfterDelete }
export {
onMailerBeforeAdminResetPasswordSend,
fireMailerBeforeAdminResetPasswordSend,
}
export {
onMailerAfterAdminResetPasswordSend,
fireMailerAfterAdminResetPasswordSend,
}
export {
onMailerBeforeUserResetPasswordSend,
fireMailerBeforeUserResetPasswordSend,
}
export {
onMailerAfterUserResetPasswordSend,
fireMailerAfterUserResetPasswordSend,
}
export {
onMailerBeforeUserVerificationSend,
fireMailerBeforeUserVerificationSend,
}
export {
onMailerAfterUserVerificationSend,
fireMailerAfterUserVerificationSend,
}
export {
onMailerBeforeUserChangeEmailSend,
fireMailerBeforeUserChangeEmailSend,
}
export { onMailerAfterUserChangeEmailSend, fireMailerAfterUserChangeEmailSend }
export { onRealtimeConnectRequest, fireRealtimeConnectRequest }
export { onRealtimeBeforeSubscribeRequest, fireRealtimeBeforeSubscribeRequest }
export { onRealtimeAfterSubscribeRequest, fireRealtimeAfterSubscribeRequest }
export { onSettingsListRequest, fireSettingsListRequest }
export { onSettingsBeforeUpdateRequest, fireSettingsBeforeUpdateRequest }
export { onSettingsAfterUpdateRequest, fireSettingsAfterUpdateRequest }
export { onFileDownloadRequest, fireFileDownloadRequest }
export { onAdminsListRequest, fireAdminsListRequest }
export { onAdminViewRequest, fireAdminViewRequest }
export { onAdminBeforeCreateRequest, fireAdminBeforeCreateRequest }
export { onAdminAfterCreateRequest, fireAdminAfterCreateRequest }
export { onAdminBeforeUpdateRequest, fireAdminBeforeUpdateRequest }
export { onAdminAfterUpdateRequest, fireAdminAfterUpdateRequest }
export { onAdminBeforeDeleteRequest, fireAdminBeforeDeleteRequest }
export { onAdminAfterDeleteRequest, fireAdminAfterDeleteRequest }
export { onAdminAuthRequest, fireAdminAuthRequest }
export { onUsersListRequest, fireUsersListRequest }
export { onUserViewRequest, fireUserViewRequest }
export { onUserBeforeCreateRequest, fireUserBeforeCreateRequest }
export { onUserAfterCreateRequest, fireUserAfterCreateRequest }
export { onUserBeforeUpdateRequest, fireUserBeforeUpdateRequest }
export { onUserAfterUpdateRequest, fireUserAfterUpdateRequest }
export { onUserBeforeDeleteRequest, fireUserBeforeDeleteRequest }
export { onUserAfterDeleteRequest, fireUserAfterDeleteRequest }
export { onUserAuthRequest, fireUserAuthRequest }
export { onUserListExternalAuths, fireUserListExternalAuths }
export {
onUserBeforeUnlinkExternalAuthRequest,
fireUserBeforeUnlinkExternalAuthRequest,
}
export {
onUserAfterUnlinkExternalAuthRequest,
fireUserAfterUnlinkExternalAuthRequest,
}
export { onRecordsListRequest, fireRecordsListRequest }
export { onRecordViewRequest, fireRecordViewRequest }
export { onRecordBeforeCreateRequest, fireRecordBeforeCreateRequest }
export { onRecordAfterCreateRequest, fireRecordAfterCreateRequest }
export { onRecordBeforeUpdateRequest, fireRecordBeforeUpdateRequest }
export { onRecordAfterUpdateRequest, fireRecordAfterUpdateRequest }
export { onRecordBeforeDeleteRequest, fireRecordBeforeDeleteRequest }
export { onRecordAfterDeleteRequest, fireRecordAfterDeleteRequest }
export { onCollectionsListRequest, fireCollectionsListRequest }
export { onCollectionViewRequest, fireCollectionViewRequest }
export { onCollectionBeforeCreateRequest, fireCollectionBeforeCreateRequest }
export { onCollectionAfterCreateRequest, fireCollectionAfterCreateRequest }
export { onCollectionBeforeUpdateRequest, fireCollectionBeforeUpdateRequest }
export { onCollectionAfterUpdateRequest, fireCollectionAfterUpdateRequest }
export { onCollectionBeforeDeleteRequest, fireCollectionBeforeDeleteRequest }
export { onCollectionAfterDeleteRequest, fireCollectionAfterDeleteRequest }
export { onCollectionsBeforeImportRequest, fireCollectionsBeforeImportRequest }
export { onCollectionsAfterImportRequest, fireCollectionsAfterImportRequest }
export { onBeforeServe, fireBeforeServe }
// OnModelAfterCreate hook is triggered after successfully
// inserting a new entry in the DB.
export type ModelAfterCreateEvent = {}
const [onModelAfterCreate, fireModelAfterCreate] =
createEvent<ModelAfterCreateEvent>(`OnModelAfterCreate`)
// OnModelBeforeUpdate hook is triggered before updating existing
// entry in the DB, allowing you to modify or validate the stored data.
export type ModelBeforeUpdateEvent = {}
const [onModelBeforeUpdate, fireModelBeforeUpdate] =
createEvent<ModelBeforeUpdateEvent>(`OnModelBeforeUpdate`)
// OnModelAfterUpdate hook is triggered after successfully updating
// existing entry in the DB.
export type ModelAfterUpdateEvent = {}
const [onModelAfterUpdate, fireModelAfterUpdate] =
createEvent<ModelAfterUpdateEvent>(`OnModelAfterUpdate`)
// OnModelBeforeDelete hook is triggered before deleting an
// existing entry from the DB.
export type ModelBeforeDeleteEvent = {}
const [onModelBeforeDelete, fireModelBeforeDelete] =
createEvent<ModelBeforeDeleteEvent>(`OnModelBeforeDelete`)
// OnModelAfterDelete is triggered after successfully deleting an
// existing entry from the DB.
export type ModelAfterDeleteEvent = {}
const [onModelAfterDelete, fireModelAfterDelete] =
createEvent<ModelAfterDeleteEvent>(`OnModelAfterDelete`)
// ---------------------------------------------------------------
// Mailer event hooks
// ---------------------------------------------------------------
// OnMailerBeforeAdminResetPasswordSend hook is triggered right before
// sending a password reset email to an admin.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
export type MailerBeforeAdminResetPasswordSendEvent = {}
const [
onMailerBeforeAdminResetPasswordSend,
fireMailerBeforeAdminResetPasswordSend,
] = createEvent<MailerBeforeAdminResetPasswordSendEvent>(
`OnMailerBeforeAdminResetPasswordSend`
)
// OnMailerAfterAdminResetPasswordSend hook is triggered after
// admin password reset email was successfully sent.
export type MailerAfterAdminResetPasswordSendEvent = {}
const [
onMailerAfterAdminResetPasswordSend,
fireMailerAfterAdminResetPasswordSend,
] = createEvent<MailerAfterAdminResetPasswordSendEvent>(
`OnMailerBeforeAdminResetPasswordSend`
)
// OnMailerBeforeUserResetPasswordSend hook is triggered right before
// sending a password reset email to a user.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
export type MailerBeforeUserResetPasswordSendEvent = {}
const [
onMailerBeforeUserResetPasswordSend,
fireMailerBeforeUserResetPasswordSend,
] = createEvent<MailerBeforeUserResetPasswordSendEvent>(
`OnMailerBeforeUserResetPasswordSend`
)
// OnMailerAfterUserResetPasswordSend hook is triggered after
// a user password reset email was successfully sent.
export type MailerAfterUserResetPasswordSendEvent = {}
const [
onMailerAfterUserResetPasswordSend,
fireMailerAfterUserResetPasswordSend,
] = createEvent<MailerAfterUserResetPasswordSendEvent>(
`OnMailerAfterUserResetPasswordSend`
)
// OnMailerBeforeUserVerificationSend hook is triggered right before
// sending a verification email to a user.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
export type MailerBeforeUserVerificationSendEvent = {}
const [
onMailerBeforeUserVerificationSend,
fireMailerBeforeUserVerificationSend,
] = createEvent<MailerBeforeUserVerificationSendEvent>(
`OnMailerBeforeUserVerificationSend`
)
// OnMailerAfterUserVerificationSend hook is triggered after a user
// verification email was successfully sent.
export type MailerAfterUserVerificationSendEvent = {}
const [onMailerAfterUserVerificationSend, fireMailerAfterUserVerificationSend] =
createEvent<MailerAfterUserVerificationSendEvent>(
`OnMailerAfterUserVerificationSend`
)
// OnMailerBeforeUserChangeEmailSend hook is triggered right before
// sending a confirmation new address email to a a user.
//
// Could be used to send your own custom email template if
// [hook.StopPropagation] is returned in one of its listeners.
export type MailerBeforeUserChangeEmailSendEvent = {}
const [onMailerBeforeUserChangeEmailSend, fireMailerBeforeUserChangeEmailSend] =
createEvent<MailerBeforeUserChangeEmailSendEvent>(
`OnMailerBeforeUserChangeEmailSend`
)
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
// change address email was successfully sent.
export type MailerAfterUserChangeEmailSendEvent = {}
const [onMailerAfterUserChangeEmailSend, fireMailerAfterUserChangeEmailSend] =
createEvent<MailerAfterUserChangeEmailSendEvent>(
`OnMailerAfterUserChangeEmailSend`
)
// ---------------------------------------------------------------
// Realtime API event hooks
// ---------------------------------------------------------------
// OnRealtimeConnectRequest hook is triggered right before establishing
// the SSE client connection.
export type RealtimeConnectRequestEvent = {}
const [onRealtimeConnectRequest, fireRealtimeConnectRequest] =
createEvent<RealtimeConnectRequestEvent>(`OnRealtimeConnectRequest`)
// OnRealtimeBeforeSubscribeRequest hook is triggered before changing
// the client subscriptions, allowing you to further validate and
// modify the submitted change.
export type RealtimeBeforeSubscribeRequestEvent = {}
const [onRealtimeBeforeSubscribeRequest, fireRealtimeBeforeSubscribeRequest] =
createEvent<RealtimeBeforeSubscribeRequestEvent>(
`OnRealtimeBeforeSubscribeRequest`
)
// OnRealtimeAfterSubscribeRequest hook is triggered after the client
// subscriptions were successfully changed.
export type RealtimeAfterSubscribeRequestEvent = {}
const [onRealtimeAfterSubscribeRequest, fireRealtimeAfterSubscribeRequest] =
createEvent<RealtimeAfterSubscribeRequestEvent>(
`OnRealtimeAfterSubscribeRequest`
)
// ---------------------------------------------------------------
// Settings API event hooks
// ---------------------------------------------------------------
// OnSettingsListRequest hook is triggered on each successful
// API Settings list request.
//
// Could be used to validate or modify the response before
// returning it to the client.
export type SettingsListRequestEvent = {}
const [onSettingsListRequest, fireSettingsListRequest] =
createEvent<SettingsListRequestEvent>(`OnSettingsListRequest`)
// OnSettingsBeforeUpdateRequest hook is triggered before each API
// Settings update request (after request data load and before settings persistence).
//
// Could be used to additionally validate the request data or
// implement completely different persistence behavior
// (returning [hook.StopPropagation]).
export type SettingsBeforeUpdateRequestEvent = {}
const [onSettingsBeforeUpdateRequest, fireSettingsBeforeUpdateRequest] =
createEvent<SettingsBeforeUpdateRequestEvent>(`OnSettingsBeforeUpdateRequest`)
// OnSettingsAfterUpdateRequest hook is triggered after each
// successful API Settings update request.
export type SettingsAfterUpdateRequestEvent = {}
const [onSettingsAfterUpdateRequest, fireSettingsAfterUpdateRequest] =
createEvent<SettingsAfterUpdateRequestEvent>(`OnSettingsAfterUpdateRequest`)
// ---------------------------------------------------------------
// File API event hooks
// ---------------------------------------------------------------
// OnFileDownloadRequest hook is triggered before each API File download request.
//
// Could be used to validate or modify the file response before
// returning it to the client.
export type FileDownloadRequestEvent = {}
const [onFileDownloadRequest, fireFileDownloadRequest] =
createEvent<FileDownloadRequestEvent>(`OnFileDownloadRequest`)
// ---------------------------------------------------------------
// Admin API event hooks
// ---------------------------------------------------------------
// OnAdminsListRequest hook is triggered on each API Admins list request.
//
// Could be used to validate or modify the response before returning it to the client.
export type AdminsListRequestEvent = {}
const [onAdminsListRequest, fireAdminsListRequest] =
createEvent<AdminsListRequestEvent>(`OnAdminsListRequest`)
// OnAdminViewRequest hook is triggered on each API Admin view request.
//
// Could be used to validate or modify the response before returning it to the client.
export type AdminViewRequestEvent = {}
const [onAdminViewRequest, fireAdminViewRequest] =
createEvent<AdminViewRequestEvent>(`OnAdminViewRequest`)
// OnAdminBeforeCreateRequest hook is triggered before each API
// Admin create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type AdminBeforeCreateRequestEvent = {}
const [onAdminBeforeCreateRequest, fireAdminBeforeCreateRequest] =
createEvent<AdminBeforeCreateRequestEvent>(`OnAdminBeforeCreateRequest`)
// OnAdminAfterCreateRequest hook is triggered after each
// successful API Admin create request.
export type AdminAfterCreateRequestEvent = {}
const [onAdminAfterCreateRequest, fireAdminAfterCreateRequest] =
createEvent<AdminAfterCreateRequestEvent>(`OnAdminAfterCreateRequest`)
// OnAdminBeforeUpdateRequest hook is triggered before each API
// Admin update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type AdminBeforeUpdateRequestEvent = {}
const [onAdminBeforeUpdateRequest, fireAdminBeforeUpdateRequest] =
createEvent<AdminBeforeUpdateRequestEvent>(`OnAdminBeforeUpdateRequest`)
// OnAdminAfterUpdateRequest hook is triggered after each
// successful API Admin update request.
export type AdminAfterUpdateRequestEvent = {}
const [onAdminAfterUpdateRequest, fireAdminAfterUpdateRequest] =
createEvent<AdminAfterUpdateRequestEvent>(`OnAdminAfterUpdateRequest`)
// OnAdminBeforeDeleteRequest hook is triggered before each API
// Admin delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
export type AdminBeforeDeleteRequestEvent = {}
const [onAdminBeforeDeleteRequest, fireAdminBeforeDeleteRequest] =
createEvent<AdminBeforeDeleteRequestEvent>(`OnAdminBeforeDeleteRequest`)
// OnAdminAfterDeleteRequest hook is triggered after each
// successful API Admin delete request.
export type AdminAfterDeleteRequestEvent = {}
const [onAdminAfterDeleteRequest, fireAdminAfterDeleteRequest] =
createEvent<AdminAfterDeleteRequestEvent>(`OnAdminAfterDeleteRequest`)
// OnAdminAuthRequest hook is triggered on each successful API Admin
// authentication request (sign-in, token refresh, etc.).
//
// Could be used to additionally validate or modify the
// authenticated admin data and token.
export type AdminAuthRequestEvent = {}
const [onAdminAuthRequest, fireAdminAuthRequest] =
createEvent<AdminAuthRequestEvent>(`OnAdminAuthRequest`)
// ---------------------------------------------------------------
// User API event hooks
// ---------------------------------------------------------------
// OnUsersListRequest hook is triggered on each API Users list request.
//
// Could be used to validate or modify the response before returning it to the client.
export type UsersListRequestEvent = {}
const [onUsersListRequest, fireUsersListRequest] =
createEvent<UsersListRequestEvent>(`OnUsersListRequest`)
// OnUserViewRequest hook is triggered on each API User view request.
//
// Could be used to validate or modify the response before returning it to the client.
export type UserViewRequestEvent = {}
const [onUserViewRequest, fireUserViewRequest] =
createEvent<UserViewRequestEvent>(`OnUserViewRequest`)
// OnUserBeforeCreateRequest hook is triggered before each API User
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type UserBeforeCreateRequestEvent = {}
const [onUserBeforeCreateRequest, fireUserBeforeCreateRequest] =
createEvent<UserBeforeCreateRequestEvent>(`OnUserBeforeCreateRequest`)
// OnUserAfterCreateRequest hook is triggered after each
// successful API User create request.
export type UserAfterCreateRequestEvent = {}
const [onUserAfterCreateRequest, fireUserAfterCreateRequest] =
createEvent<UserAfterCreateRequestEvent>(`OnUserAfterCreateRequest`)
// OnUserBeforeUpdateRequest hook is triggered before each API User
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type UserBeforeUpdateRequestEvent = {}
const [onUserBeforeUpdateRequest, fireUserBeforeUpdateRequest] =
createEvent<UserBeforeUpdateRequestEvent>(`OnUserBeforeUpdateRequest`)
// OnUserAfterUpdateRequest hook is triggered after each
// successful API User update request.
export type UserAfterUpdateRequestEvent = {}
const [onUserAfterUpdateRequest, fireUserAfterUpdateRequest] =
createEvent<UserAfterUpdateRequestEvent>(`OnUserAfterUpdateRequest`)
// OnUserBeforeDeleteRequest hook is triggered before each API User
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
export type UserBeforeDeleteRequestEvent = {}
const [onUserBeforeDeleteRequest, fireUserBeforeDeleteRequest] =
createEvent<UserBeforeDeleteRequestEvent>(`OnUserBeforeDeleteRequest`)
// OnUserAfterDeleteRequest hook is triggered after each
// successful API User delete request.
export type UserAfterDeleteRequestEvent = {}
const [onUserAfterDeleteRequest, fireUserAfterDeleteRequest] =
createEvent<UserAfterDeleteRequestEvent>(`OnUserAfterDeleteRequest`)
// OnUserAuthRequest hook is triggered on each successful API User
// authentication request (sign-in, token refresh, etc.).
//
// Could be used to additionally validate or modify the
// authenticated user data and token.
export type UserAuthRequestEvent = {}
const [onUserAuthRequest, fireUserAuthRequest] =
createEvent<UserAuthRequestEvent>(`OnUserAuthRequest`)
// OnUserListExternalAuths hook is triggered on each API user's external auths list request.
//
// Could be used to validate or modify the response before returning it to the client.
export type UserListExternalAuthsEvent = {}
const [onUserListExternalAuths, fireUserListExternalAuths] =
createEvent<UserListExternalAuthsEvent>(`OnUserListExternalAuths`)
// OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's
// external auth unlink request (after models load and before the actual relation deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
export type UserBeforeUnlinkExternalAuthRequestEvent = {}
const [
onUserBeforeUnlinkExternalAuthRequest,
fireUserBeforeUnlinkExternalAuthRequest,
] = createEvent<UserBeforeUnlinkExternalAuthRequestEvent>(
`OnUserBeforeUnlinkExternalAuthRequest`
)
// OnUserAfterUnlinkExternalAuthRequest hook is triggered after each
// successful API user's external auth unlink request.
export type UserAfterUnlinkExternalAuthRequestEvent = {}
const [
onUserAfterUnlinkExternalAuthRequest,
fireUserAfterUnlinkExternalAuthRequest,
] = createEvent<UserAfterUnlinkExternalAuthRequestEvent>(
`OnUserAfterUnlinkExternalAuthRequest`
)
// ---------------------------------------------------------------
// Record API event hooks
// ---------------------------------------------------------------
// OnRecordsListRequest hook is triggered on each API Records list request.
//
// Could be used to validate or modify the response before returning it to the client.
export type RecordsListRequestEvent = {}
const [onRecordsListRequest, fireRecordsListRequest] =
createEvent<RecordsListRequestEvent>(`OnRecordsListRequest`)
// OnRecordViewRequest hook is triggered on each API Record view request.
//
// Could be used to validate or modify the response before returning it to the client.
export type RecordViewRequestEvent = {}
const [onRecordViewRequest, fireRecordViewRequest] =
createEvent<RecordViewRequestEvent>(`OnRecordViewRequest`)
// OnRecordBeforeCreateRequest hook is triggered before each API Record
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type RecordBeforeCreateRequestEvent = {}
const [onRecordBeforeCreateRequest, fireRecordBeforeCreateRequest] =
createEvent<RecordBeforeCreateRequestEvent>(`OnRecordBeforeCreateRequest`)
// OnRecordAfterCreateRequest hook is triggered after each
// successful API Record create request.
export type RecordAfterCreateRequestEvent = {}
const [onRecordAfterCreateRequest, fireRecordAfterCreateRequest] =
createEvent<RecordAfterCreateRequestEvent>(`OnRecordAfterCreateRequest`)
// OnRecordBeforeUpdateRequest hook is triggered before each API Record
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type RecordBeforeUpdateRequestEvent = {}
const [onRecordBeforeUpdateRequest, fireRecordBeforeUpdateRequest] =
createEvent<RecordBeforeUpdateRequestEvent>(`OnRecordBeforeUpdateRequest`)
// OnRecordAfterUpdateRequest hook is triggered after each
// successful API Record update request.
export type RecordAfterUpdateRequestEvent = {}
const [onRecordAfterUpdateRequest, fireRecordAfterUpdateRequest] =
createEvent<RecordAfterUpdateRequestEvent>(`OnRecordAfterUpdateRequest`)
// OnRecordBeforeDeleteRequest hook is triggered before each API Record
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
export type RecordBeforeDeleteRequestEvent = {}
const [onRecordBeforeDeleteRequest, fireRecordBeforeDeleteRequest] =
createEvent<RecordBeforeDeleteRequestEvent>(`OnRecordBeforeDeleteRequest`)
// OnRecordAfterDeleteRequest hook is triggered after each
// successful API Record delete request.
export type RecordAfterDeleteRequestEvent = {}
const [onRecordAfterDeleteRequest, fireRecordAfterDeleteRequest] =
createEvent<RecordAfterDeleteRequestEvent>(`OnRecordAfterDeleteRequest`)
// ---------------------------------------------------------------
// Collection API event hooks
// ---------------------------------------------------------------
// OnCollectionsListRequest hook is triggered on each API Collections list request.
//
// Could be used to validate or modify the response before returning it to the client.
export type CollectionsListRequestEvent = {}
const [onCollectionsListRequest, fireCollectionsListRequest] =
createEvent<CollectionsListRequestEvent>(`OnCollectionsListRequest`)
// OnCollectionViewRequest hook is triggered on each API Collection view request.
//
// Could be used to validate or modify the response before returning it to the client.
export type CollectionViewRequestEvent = {}
const [onCollectionViewRequest, fireCollectionViewRequest] =
createEvent<CollectionViewRequestEvent>(`OnCollectionViewRequest`)
// OnCollectionBeforeCreateRequest hook is triggered before each API Collection
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type CollectionBeforeCreateRequestEvent = {}
const [onCollectionBeforeCreateRequest, fireCollectionBeforeCreateRequest] =
createEvent<CollectionBeforeCreateRequestEvent>(
`OnCollectionBeforeCreateRequest`
)
// OnCollectionAfterCreateRequest hook is triggered after each
// successful API Collection create request.
export type CollectionAfterCreateRequestEvent = {}
const [onCollectionAfterCreateRequest, fireCollectionAfterCreateRequest] =
createEvent<CollectionAfterCreateRequestEvent>(
`OnCollectionAfterCreateRequest`
)
// OnCollectionBeforeUpdateRequest hook is triggered before each API Collection
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
export type CollectionBeforeUpdateRequestEvent = {}
const [onCollectionBeforeUpdateRequest, fireCollectionBeforeUpdateRequest] =
createEvent<CollectionBeforeUpdateRequestEvent>(
`OnCollectionBeforeUpdateRequest`
)
// OnCollectionAfterUpdateRequest hook is triggered after each
// successful API Collection update request.
export type CollectionAfterUpdateRequestEvent = {}
const [onCollectionAfterUpdateRequest, fireCollectionAfterUpdateRequest] =
createEvent<CollectionAfterUpdateRequestEvent>(
`OnCollectionAfterUpdateRequest`
)
// OnCollectionBeforeDeleteRequest hook is triggered before each API
// Collection delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning [hook.StopPropagation]).
export type CollectionBeforeDeleteRequestEvent = {}
const [onCollectionBeforeDeleteRequest, fireCollectionBeforeDeleteRequest] =
createEvent<CollectionBeforeDeleteRequestEvent>(
`OnCollectionBeforeDeleteRequest`
)
// OnCollectionAfterDeleteRequest hook is triggered after each
// successful API Collection delete request.
export type CollectionAfterDeleteRequestEvent = {}
const [onCollectionAfterDeleteRequest, fireCollectionAfterDeleteRequest] =
createEvent<CollectionAfterDeleteRequestEvent>(
`OnCollectionAfterDeleteRequest`
)
// OnCollectionsBeforeImportRequest hook is triggered before each API
// collections import request (after request data load and before the actual import).
//
// Could be used to additionally validate the imported collections or
// to implement completely different import behavior (returning [hook.StopPropagation]).
export type CollectionsBeforeImportRequestEvent = {}
const [onCollectionsBeforeImportRequest, fireCollectionsBeforeImportRequest] =
createEvent<CollectionsBeforeImportRequestEvent>(
`OnCollectionsBeforeImportRequest`
)
// OnCollectionsAfterImportRequest hook is triggered after each
// successful API collections import request.
export type CollectionsAfterImportRequestEvent = {}
const [onCollectionsAfterImportRequest, fireCollectionsAfterImportRequest] =
createEvent<CollectionsAfterImportRequestEvent>(
`OnCollectionsAfterImportRequest`
)
export type BeforeServeEvent = {}
const [onBeforeServe, fireBeforeServe] =
createEvent<BeforeServeEvent>(`OnBeforeServe`)

View File

@ -0,0 +1,11 @@
export * from './routes'
export * from './transaction'
export * from './types'
import { PBScriptApi } from './types/PBScriptApi'
const api: PBScriptApi = {
ping: () => 'Hello from PBScript!',
}
registerJsFuncs(api)

View File

@ -0,0 +1,71 @@
import {
EchoHttpMethods,
EchoHttpResponseStatuses,
EchoMiddlewareFunc,
EchoRoute,
User,
} from './types'
export type HandlerApi = {
ok: (s: string) => void
error: (s: string) => void
user: () => User
}
export type HandlerFunc = (api: HandlerApi) => void
export type Route = {
method: EchoHttpMethods
path: string
handler: HandlerFunc
middlewares: EchoMiddlewareFunc[]
}
export const addRoute = (route: Route) => {
// Ensure path begins with `/api/`
const { path } = route
if (!path.startsWith(`/api/`)) {
console.log(`API error ${path}`)
throw new Error(`All routes must take the form /api/<path...>`)
}
const _route: EchoRoute = {
method: route.method,
path: route.path,
handler: (c) => {
const api: HandlerApi = {
ok: (message) => {
c.string(EchoHttpResponseStatuses.Ok, message)
},
error: (message) => {
c.string(EchoHttpResponseStatuses.Error, message)
},
user: () => c.get<User>('user'),
}
route.handler(api)
},
middlewares: route.middlewares,
}
return __go.addRoute(_route)
}
export const auth = __go.requireAdminOrUserAuth()
export const get = (
path: string,
handler: HandlerFunc,
middlewares?: EchoMiddlewareFunc[]
) => {
addRoute({
method: EchoHttpMethods.Get,
path: `/api${path}`,
handler: (api) => {
try {
handler(api)
} catch (e) {
api.error(`${e}`)
}
},
middlewares: middlewares || [],
})
}

View File

@ -0,0 +1,36 @@
export type TransactionApi = {
execute: (sql: string) => void
all: <TRow>(sql: string) => TRow[]
one: <TRow>(sql: string) => TRow
}
export type TransactionCallback = (api: TransactionApi) => void
export const runInTransaction = (cb: TransactionCallback) => {
__go.app.dao().runInTransaction((txDao) => {
const execute = (sql: string) => {
const q = txDao.dB().newQuery(sql)
return q.execute()
}
const all = <TRow>(sql: string): TRow[] => {
const q = txDao.dB().newQuery(sql)
const rowPtr = __go.newNullStringMapArrayPtr<TRow>()
q.all(rowPtr)
console.log({ rowPtr })
return []
}
const one = <TRow>(sql: string): TRow => {
const q = txDao.dB().newQuery(sql)
const row = __go.newNullStringMap<TRow>()
q.one(row)
return row
}
const api: TransactionApi = {
execute,
all,
one,
}
cb(api)
})
}

View File

@ -0,0 +1,3 @@
export type ConsoleApi = {
log: (...args: any) => void
}

View File

@ -0,0 +1,17 @@
import { Dao } from './go-namespaces/Dao'
import { EchoMiddlewareFunc, EchoRoute } from './go-namespaces/Echo'
export type GoApi = {
app: {
dao: () => Dao
}
addRoute: (route: EchoRoute) => void
ping: () => string
onModelBeforeCreate: any
requireAdminAuth: () => EchoMiddlewareFunc
requireAdminAuthOnlyIfAny: () => EchoMiddlewareFunc
requireAdminOrOwnerAuth: () => EchoMiddlewareFunc
requireAdminOrUserAuth: () => EchoMiddlewareFunc
newNullStringMapArrayPtr: <TFields>() => TFields[]
newNullStringMap: <TFields>() => TFields
}

View File

@ -0,0 +1,4 @@
export type PingResult = string
export type PBScriptApi = {
ping: () => PingResult
}

View File

@ -0,0 +1,9 @@
import { ConsoleApi } from './ConsoleApi'
import { GoApi } from './GoApi'
import { PBScriptApi } from './PBScriptApi'
declare global {
let console: ConsoleApi
function registerJsFuncs(api: PBScriptApi): void
let __go: GoApi
}

View File

@ -0,0 +1,47 @@
import { IsoDate, RecordId, Url } from '.'
export type ProfileId = RecordId
export type Email = string
export type UserId = RecordId
export type Token = string
export type PasswordHash = string
export type User = {
baseAccount: {
baseModel: {
id: UserId
created: IsoDate
updated: IsoDate
}
id: UserId
created: IsoDate
updated: IsoDate
email: Email
tokenKey: Token
passwordHash: PasswordHash
lastResetSentAt: IsoDate
}
baseModel: {
id: UserId
created: IsoDate
updated: IsoDate
}
id: UserId
created: IsoDate
updated: IsoDate
email: Email
tokenKey: Token
passwordHash: PasswordHash
lastResetSentAt: IsoDate
verified: boolean
lastVerificationSentAt: IsoDate
profile: {
avatar: Url
created: IsoDate
id: ProfileId
name: string
updated: IsoDate
userId: UserId
}
}

View File

@ -0,0 +1,5 @@
export type RecordId = string
export type IsoDate = string
export type Url = string
export * from './User'

View File

@ -0,0 +1,9 @@
import { DbxBuilder } from './Dbx'
export type DaoTransactionCallback = (txDao: Dao) => void
export type Dao = {
// https://pkg.go.dev/github.com/pocketbase/pocketbase@v0.7.9/daos
runInTransaction: (cb: DaoTransactionCallback) => void
dB: () => DbxBuilder
}

View File

@ -0,0 +1,14 @@
import { SqlResult } from './Sql'
export type DbxQuery = {
// https://pkg.go.dev/github.com/pocketbase/dbx#Query
// https://pkg.go.dev/github.com/pocketbase/dbx#Query.Execute
execute: () => SqlResult
all: <TRow>(rows: TRow[]) => void
one: <TRow>(row: TRow) => void
}
export type DbxBuilder = {
// https://pkg.go.dev/github.com/pocketbase/dbx#Builder
newQuery: (sql: string) => DbxQuery
}

View File

@ -0,0 +1,25 @@
export enum EchoHttpMethods {
Get = 'GET',
}
export enum EchoHttpResponseStatuses {
Ok = 200,
Error = 400,
}
export type EchoHandlerFunc = (context: EchoContext) => void
export type EchoMiddlewareFunc = (next: EchoHandlerFunc) => EchoHandlerFunc
export type EchoContext = {
get: <T>(key: string) => T
string: (code: number, s: string) => void
json: (status: EchoHttpResponseStatuses, data: object) => void
}
export type EchoRoute = {
method: EchoHttpMethods
path: string
handler: EchoHandlerFunc
middlewares: EchoMiddlewareFunc[]
}

View File

@ -0,0 +1,10 @@
export type SqlResult = {
// https://pkg.go.dev/database/sql#Result
// statements varies.
lastInsertId: () => number
// RowsAffected returns the number of rows affected by an
// update, insert, or delete. Not every database or database
// driver may support this.
rowsAffected: () => number
}

View File

@ -0,0 +1,4 @@
export * from './Dao'
export * from './Dbx'
export * from './Echo'
export * from './Sql'

View File

@ -0,0 +1,5 @@
export * from './database'
export * from './go-namespaces'
export * from './GoApi'
export * from './PBScriptApi'
export * from './__global'

View File

@ -0,0 +1,8 @@
export function assertExists<TType>(
v: TType,
message = `Value does not exist`
): asserts v is NonNullable<TType> {
if (typeof v === 'undefined') {
throw new Error(message)
}
}

View File

@ -0,0 +1,34 @@
import { createNanoEvents } from 'nanoevents'
const emitter = createNanoEvents()
export type Event<TPayload extends any = void> = TPayload
export type EventListenerCallback<TEvent extends Event<any>> = (
event: TEvent
) => void
export type EventDispatcher<TEvent extends Event<any> = Event> = (
event: TEvent
) => void
export type EventUnsubscriber = () => void
export type EventSubscriber<TEvent extends Event<any>> = (
callback: EventListenerCallback<TEvent>
) => EventUnsubscriber
export const createEvent = <TEvent extends Event<any>>(
eventName: string
): [EventSubscriber<TEvent>, EventDispatcher<TEvent>] => {
const fire: EventDispatcher<TEvent> = (event) => {
emitter.emit(eventName, event)
}
const listen: EventSubscriber<TEvent> = (cb) => {
const unsub = emitter.on(eventName, cb)
return unsub
}
return [listen, fire]
}

View File

@ -0,0 +1,3 @@
export function isFunction(value: any) {
return typeof value === 'function'
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"module": "ES2015",
"moduleResolution": "node",
"noUncheckedIndexedAccess": true,
"strictNullChecks": true,
"typeRoots": ["./node_modules/@types"],
"inlineSourceMap": true,
"lib": ["ES2015"]
},
"include": ["./src"]
}

View File

@ -0,0 +1,376 @@
package engine
import (
"fmt"
"net/http"
"unsafe"
"github.com/benallfree/pbscript/modules/pbscript/event"
"github.com/goccy/go-json"
"github.com/dop251/goja"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
var app *pocketbase.PocketBase
var router *echo.Echo
var vm *goja.Runtime
var cleanups = []func(){}
var __go_apis *goja.Object
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorBlue = "\033[34m"
colorPurple = "\033[35m"
colorCyan = "\033[36m"
colorWhite = "\033[37m"
)
func logErrorf(format string, args ...any) (n int, err error) {
s := append(args, string(colorReset))
fmt.Print(colorRed)
res, err := fmt.Printf(format, s...)
fmt.Print(colorReset)
return res, err
}
func bindApis() {
__go_apis = vm.NewObject()
__go_apis.Set("addRoute", func(route echo.Route) {
method := route.Method
path := route.Path
fmt.Printf("Adding route: %s %s\n", method, path)
router.AddRoute(route)
cleanup(
fmt.Sprintf("route %s %s", method, path),
func() {
router.Router().Remove(method, path)
})
})
__go_apis.Set("onModelBeforeCreate", func(cb func(e *core.ModelEvent)) {
fmt.Println("Listening in Go for onModelBeforeCreate")
unsub := event.On(event.EVT_ON_MODEL_BEFORE_CREATE, func(e *event.UnknownPayload) {
// fmt.Println("syntheticevent: OnModelBeforeCreate")
// fmt.Println("e", e)
// fmt.Println("cb", cb)
cb((*core.ModelEvent)(unsafe.Pointer(e)))
})
cleanup("onModelBeforeCreate", unsub)
})
__go_apis.Set("onModelAfterCreate", func(cb func(e *core.ModelEvent)) {
fmt.Println("Listening in Go for onModelAfterCreate")
unsub := event.On(event.EVT_ON_MODEL_AFTER_CREATE, func(e *event.UnknownPayload) {
// fmt.Println("syntheticevent: OnModelAfterCreate")
// fmt.Println("e", e)
// fmt.Println("cb", cb)
cb((*core.ModelEvent)(unsafe.Pointer(e)))
})
cleanup("onModelAfterCreate", unsub)
})
// type TransactionApi struct {
// Execute func(sql string)
// }
// __go_apis.Set("withTransaction", func(cb func(e *TransactionApi)) {
// app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// var api = TransactionApi{
// Execute: func(sql string) error {
// res, err := txDao.DB().Select().NewQuery(sql).Execute()
// if err != nil {
// return err
// }
// }}
// })
// })
__go_apis.Set("requireAdminAuth", apis.RequireAdminAuth)
__go_apis.Set("requireAdminAuthOnlyIfAny", apis.RequireAdminAuthOnlyIfAny)
__go_apis.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth)
__go_apis.Set("requireAdminOrUserAuth", apis.RequireAdminOrUserAuth)
__go_apis.Set("app", app)
__go_apis.Set("ping", func() string {
return "Hello from Go!"
})
__go_apis.Set("newNullStringMapArrayPtr", func() *[]dbx.NullStringMap {
var users2 []dbx.NullStringMap
return &users2
})
__go_apis.Set("newNullStringMap", func() dbx.NullStringMap {
var users2 dbx.NullStringMap
return users2
})
}
func cleanup(msg string, cb func()) {
fmt.Printf("adding cleanup: %s\n", msg)
cleanups = append(cleanups, func() {
fmt.Printf("executing cleanup: %s\n", msg)
cb()
})
}
func loadActiveScript() (string, error) {
collection, err := app.Dao().FindCollectionByNameOrId("pbscript")
if err != nil {
return "", err
}
recs, err := app.Dao().FindRecordsByExpr(collection, dbx.HashExp{"type": "script", "isActive": true})
if err != nil {
return "", err
}
if len(recs) > 1 {
return "", fmt.Errorf("expected one active script record but got %d", len(recs))
}
if len(recs) == 0 {
return "", nil // Empty script
}
rec := recs[0]
jsonData := rec.GetStringDataValue("data")
type Data struct {
Source string `json:"source"`
}
var json_map Data
err = json.Unmarshal([]byte(jsonData), &json_map)
if err != nil {
return "", err
}
script := json_map.Source
fmt.Printf("Script has been loaded.\n")
return script, nil
}
func reloadVm() error {
fmt.Println("Initializing PBScript engine")
vm = goja.New()
vm.SetFieldNameMapper(goja.UncapFieldNameMapper())
// Clean up all handlers
fmt.Println("Executing cleanups")
for i := 0; i < len(cleanups); i++ {
cleanups[i]()
}
cleanups = nil
// Load the main script
fmt.Println("Loading JS")
script, err := loadActiveScript()
if err != nil {
return err
}
// Console proxy
fmt.Println("Creating console proxy")
console := vm.NewObject()
console.Set("log", func(s ...goja.Value) {
for _, v := range s {
fmt.Printf("%s ", v.String())
}
fmt.Print("\n")
})
vm.Set("console", console)
fmt.Println("Creating apis proxy")
bindApis()
vm.Set("__go", __go_apis)
fmt.Println("Go initialization complete. Running script.")
source := fmt.Sprintf(`
console.log('Top of PBScript bootstrap')
let __jsfuncs = {ping: ()=>'Hello from PBScript!'}
function registerJsFuncs(funcs) {
__jsfuncs = {__jsfuncs, ...funcs }
}
%s
console.log('Pinging Go')
console.log('Pinging Go succeeded with:', __go.ping())
console.log('Bottom of PBScript bootstrap')
`, script)
_, err = vm.RunString(source)
if err != nil {
return err
}
// js api wireup
fmt.Println("Wiring up JS API")
type S struct {
Ping func() (string, *goja.Exception) `json:"ping"`
}
jsFuncs := S{}
err = vm.ExportTo(vm.Get("__jsfuncs"), &jsFuncs)
if err != nil {
return err
}
{
fmt.Println("Pinging JS")
res, err := jsFuncs.Ping()
if err != nil {
return fmt.Errorf("ping() failed with %s", err.Value().Export())
} else {
fmt.Printf("Ping succeeded with: %s\n", res)
}
}
return nil
}
func migrate() error {
fmt.Println("Finding collection")
_, err := app.Dao().FindCollectionByNameOrId("anything")
fmt.Println("Finished collection")
if err != nil {
err = app.Dao().SaveCollection(&models.Collection{
Name: "pbscript",
Schema: schema.NewSchema(
&schema.SchemaField{
Type: schema.FieldTypeText,
Name: "type",
},
&schema.SchemaField{
Type: schema.FieldTypeBool,
Name: "isActive",
},
&schema.SchemaField{
Type: schema.FieldTypeJson,
Name: "data",
},
),
})
if err != nil {
return err
}
}
return nil
}
func watchForScriptChanges() {
app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
if e.Model.TableName() == "pbscript" {
reloadVm()
}
return nil
})
app.OnModelAfterCreate().Add(func(e *core.ModelEvent) error {
if e.Model.TableName() == "pbscript" {
reloadVm()
}
return nil
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// add new "GET /api/hello" route
e.Router.AddRoute(echo.Route{
Method: http.MethodPost,
Path: "/api/pbscript/deploy",
Handler: func(c echo.Context) error {
json_map := make(map[string]interface{})
err := json.NewDecoder(c.Request().Body).Decode(&json_map)
if err != nil {
return err
}
//json_map has the JSON Payload decoded into a map
src := json_map["source"]
err = app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
fmt.Println("Deactivating active script")
_, err := txDao.DB().
NewQuery("UPDATE pbscript SET isActive=false WHERE type='script'").Execute()
if err != nil {
return err
}
fmt.Println("Packaging new record data")
bytes, err := json.Marshal(dbx.Params{"source": src})
if err != nil {
return err
}
_json := string(bytes)
fmt.Println("Saving new model")
collection, err := txDao.FindCollectionByNameOrId("pbscript")
if err != nil {
return err
}
record := models.NewRecord(collection)
record.SetDataValue("type", "script")
record.SetDataValue("isActive", "true")
record.SetDataValue("data", _json)
err = txDao.SaveRecord(record)
if err != nil {
return err
}
fmt.Println(("Record saved"))
// _, err = txDao.DB().
// NewQuery("INSERT INTO pbscript (type,isActive,data) values ('script', true, {data})").Bind(dbx.Params{"data": _json}).Execute()
// if err != nil {
// return err
// }
return nil
})
if err != nil {
return err
}
return c.String(http.StatusOK, "ok")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminAuth(),
},
})
return nil
})
}
func initAppEvents() {
app.OnModelBeforeCreate().Add(func(e *core.ModelEvent) error {
fmt.Println("event: OnModelBeforeCreate")
event.Fire(event.EVT_ON_MODEL_BEFORE_CREATE, (*event.UnknownPayload)(unsafe.Pointer(e)))
return nil
})
app.OnModelAfterCreate().Add(func(e *core.ModelEvent) error {
fmt.Println("event: OnModelAfterCreate")
event.Fire(event.EVT_ON_MODEL_AFTER_CREATE, (*event.UnknownPayload)(unsafe.Pointer(e)))
return nil
})
}
func StartPBScript(_app *pocketbase.PocketBase) error {
app = _app
watchForScriptChanges()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
migrate()
initAppEvents()
router = e.Router
err := reloadVm()
if err != nil {
logErrorf("Error loading VM: %s\n", err)
}
return nil
})
return nil
}

View File

@ -0,0 +1,49 @@
package event
import "fmt"
type UnknownPayload any
var inc = 0
var events = map[string]map[int]func(payload *UnknownPayload){}
const (
EVT_ON_MODEL_BEFORE_CREATE = "OnModelBeforeCreate"
EVT_ON_MODEL_AFTER_CREATE = "OnModelAfterCreate"
)
func isValid(eventName string) bool {
return eventName == EVT_ON_MODEL_BEFORE_CREATE || eventName== EVT_ON_MODEL_AFTER_CREATE
}
func ensureEvent(eventName string) {
if !isValid((eventName)) {
panic(fmt.Sprintf("%s is not a valid event name", eventName))
}
if _, ok := events[eventName]; !ok {
fmt.Printf("Creating collection for %s\n", eventName)
events[eventName] = make(map[int]func(payload *UnknownPayload))
}
}
func On(eventName string, cb func(payload *UnknownPayload)) func() {
ensureEvent(eventName)
inc++
idx := inc
events[eventName][idx] = cb
fmt.Printf("Adding %d to %s\n", idx, eventName)
return func() {
delete(events[eventName], idx)
}
}
func Fire(eventName string, payload *UnknownPayload) {
ensureEvent(eventName)
fmt.Printf("Firing %s\n", eventName)
for fnId, v := range events[eventName] {
fmt.Printf("Dispatching %s to %d\n", eventName, fnId)
v(payload)
}
}

View File

@ -0,0 +1,90 @@
module github.com/benallfree/pbscript/modules/pbscript
go 1.19
require (
github.com/dop251/goja v0.0.0-20220927172339-ea66e911853d
github.com/goccy/go-json v0.9.11
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
github.com/pocketbase/dbx v1.6.0
github.com/pocketbase/pocketbase v0.7.5
)
require (
github.com/AlecAivazis/survey/v2 v2.3.5 // indirect
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/aws/aws-sdk-go v1.44.85 // indirect
github.com/aws/aws-sdk-go-v2 v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.17.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.12.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.27.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.16.13 // indirect
github.com/aws/smithy-go v1.12.1 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/domodwyer/mailyak/v3 v3.3.4 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.1 // indirect
github.com/ganigeorgiev/fexpr v0.1.1 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.5.1 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.15 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.5.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
go.opencensus.io v0.23.0 // indirect
gocloud.dev v0.26.0 // indirect
golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503 // indirect
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220822230855-b0a4917ee28c // indirect
golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 // indirect
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
golang.org/x/tools v0.1.12 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/api v0.94.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc // indirect
google.golang.org/grpc v1.49.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.36.3 // indirect
modernc.org/ccgo/v3 v3.16.9 // indirect
modernc.org/libc v1.17.0 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.2.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.18.1 // indirect
modernc.org/strutil v1.1.2 // indirect
modernc.org/token v1.0.0 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
package pocketbase
import (
"github.com/benallfree/pbscript/modules/pbscript/engine"
"github.com/pocketbase/pocketbase"
)
func New() *pocketbase.PocketBase {
app := pocketbase.New()
engine.StartPBScript(app)
return app
}

View File

@ -129,6 +129,10 @@ open https://pockethost.io
# Release History
**next**
- [x] Migrated PBScript repository to here
**0.3.1**
- [x] OpenGraph support