chore(multi): move to plugin.ts

This commit is contained in:
Ben Allfree 2024-06-29 13:48:30 -07:00
parent 597d42d3ea
commit 7b8248a486
6 changed files with 365 additions and 363 deletions

View File

@ -1,25 +1,3 @@
import { info } from 'console'
import { PocketHostPlugin, onIncomingRequestAction } from 'pockethost'
import { PLUGIN_NAME } from './constants'
import { dbg } from './log'
const plugin: PocketHostPlugin = async ({}) => {
dbg(`initializing ${PLUGIN_NAME}`)
onIncomingRequestAction(async ({ req, res }) => {
const url = new URL(`http://${req.headers.host}${req.url}`)
const country = (req.headers['cf-ipcountry'] as string) || '<ct>'
const ip = (req.headers['x-forwarded-for'] as string) || '<ip>'
const method = req.method || '<m>'
const sig = [
method.padStart(10),
country.padStart(5),
ip.padEnd(45),
url.toString(),
].join(' ')
info(`Incoming request ${sig}`)
res.locals.sig = sig
})
}
import { plugin } from './plugin'
export default plugin

View File

@ -0,0 +1,23 @@
import { info } from 'console'
import { PocketHostPlugin, onIncomingRequestAction } from 'pockethost'
import { PLUGIN_NAME } from './constants'
import { dbg } from './log'
export const plugin: PocketHostPlugin = async ({}) => {
dbg(`initializing ${PLUGIN_NAME}`)
onIncomingRequestAction(async ({ req, res }) => {
const url = new URL(`http://${req.headers.host}${req.url}`)
const country = (req.headers['cf-ipcountry'] as string) || '<ct>'
const ip = (req.headers['x-forwarded-for'] as string) || '<ip>'
const method = req.method || '<m>'
const sig = [
method.padStart(10),
country.padStart(5),
ip.padEnd(45),
url.toString(),
].join(' ')
info(`Incoming request ${sig}`)
res.locals.sig = sig
})
}

View File

@ -1,58 +1,3 @@
import stringify from 'json-stringify-safe'
import {
LogLevelName,
PocketHostPlugin,
isLevelGte,
isLevelLte,
onLogAction,
} from 'pockethost'
import { PLUGIN_NAME } from './constants'
import { dbg } from './log'
export const LogLevelConsoleMap = {
[LogLevelName.Trace]: console.trace,
[LogLevelName.Raw]: console.log,
[LogLevelName.Debug]: console.debug,
[LogLevelName.Info]: console.info,
[LogLevelName.Warn]: console.warn,
[LogLevelName.Error]: console.error,
[LogLevelName.Abort]: console.error,
} as const
const plugin: PocketHostPlugin = async ({}) => {
onLogAction(async ({ currentLevel, levelIn, args }) => {
if (!isLevelGte(levelIn, currentLevel)) return
const finalArgs = []
if (args.length > 0) {
finalArgs.push(args.shift())
}
while (args.length > 0) {
let arg = args.shift()
if (arg instanceof Error) {
finalArgs.push(...[arg.name, arg.message.toString()])
if (isLevelLte(levelIn, LogLevelName.Debug) && arg.stack) {
finalArgs.push(...arg.stack.split(/\n/))
}
continue
}
if (typeof arg === 'string') {
finalArgs.push(arg)
continue
}
if (typeof arg === 'object') {
finalArgs.push(stringify(arg, null, 2))
continue
}
if (typeof arg === 'function') {
finalArgs.push(`<<function ${stringify(arg.toString())}>>`)
continue
}
finalArgs.push(arg)
}
LogLevelConsoleMap[levelIn](...finalArgs)
})
dbg(`initializing ${PLUGIN_NAME}`)
}
import { plugin } from './plugin'
export default plugin

View File

@ -0,0 +1,56 @@
import stringify from 'json-stringify-safe'
import {
LogLevelName,
PocketHostPlugin,
isLevelGte,
isLevelLte,
onLogAction,
} from 'pockethost'
import { PLUGIN_NAME } from './constants'
import { dbg } from './log'
export const LogLevelConsoleMap = {
[LogLevelName.Trace]: console.trace,
[LogLevelName.Raw]: console.log,
[LogLevelName.Debug]: console.debug,
[LogLevelName.Info]: console.info,
[LogLevelName.Warn]: console.warn,
[LogLevelName.Error]: console.error,
[LogLevelName.Abort]: console.error,
} as const
export const plugin: PocketHostPlugin = async ({}) => {
onLogAction(async ({ currentLevel, levelIn, args }) => {
if (!isLevelGte(levelIn, currentLevel)) return
const finalArgs = []
if (args.length > 0) {
finalArgs.push(args.shift())
}
while (args.length > 0) {
let arg = args.shift()
if (arg instanceof Error) {
finalArgs.push(...[arg.name, arg.message.toString()])
if (isLevelLte(levelIn, LogLevelName.Debug) && arg.stack) {
finalArgs.push(...arg.stack.split(/\n/))
}
continue
}
if (typeof arg === 'string') {
finalArgs.push(arg)
continue
}
if (typeof arg === 'object') {
finalArgs.push(stringify(arg, null, 2))
continue
}
if (typeof arg === 'function') {
finalArgs.push(`<<function ${stringify(arg.toString())}>>`)
continue
}
finalArgs.push(arg)
}
LogLevelConsoleMap[levelIn](...finalArgs)
})
dbg(`initializing ${PLUGIN_NAME}`)
}

View File

@ -1,286 +1,3 @@
import { Mutex } from 'async-mutex'
import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import getPort from 'get-port'
import { globSync } from 'glob'
import { gobot } from 'gobot'
import path from 'path'
import {
Bind,
InstanceFields,
PocketHostPlugin,
doAfterInstanceStartedAction,
doAfterInstanceStoppedAction,
doInstanceConfigFilter,
doInstanceLogAction,
mkInstance,
onAfterInstanceFoundAction,
onAfterServerStartAction,
onGetInstanceByRequestInfoFilter,
onGetOrProvisionInstanceUrlFilter,
onNewInstanceRecordFilter,
} from 'pockethost'
import {
APEX_DOMAIN,
INSTANCE_DATA_DIR,
PORT,
exitHook,
tryFetch,
} from 'pockethost/core'
import { gte } from 'semver'
import { PLUGIN_NAME } from './constants'
import { dbg, info } from './log'
const deleteFiles = (globPattern: string) => {
const files = globSync(globPattern)
files.forEach((file) => {
dbg(`Deleting ${file}`)
fs.unlinkSync(file)
})
}
const copyFiles = (binds: Bind[], destination: string) => {
binds.forEach((bind) => {
const srcFiles = globSync(bind.src)
srcFiles.forEach((srcFile) => {
const relativePath = path.relative(bind.base, srcFile)
const destFile = path.join(destination, relativePath)
const destDir = path.dirname(destFile)
dbg(`Copying ${srcFile} to ${destFile}`, { relativePath, destDir, bind })
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
fs.copyFileSync(srcFile, destFile)
})
})
}
const escape = (path: string) => `"${path}"`
const metaPath = (name: string) => {
const instanceRootDir = INSTANCE_DATA_DIR(name)
mkdirSync(instanceRootDir, { recursive: true })
const instanceMetaFile = INSTANCE_DATA_DIR(name, `meta.json`)
return instanceMetaFile
}
const readMeta = (path: string) =>
JSON.parse(readFileSync(path, 'utf-8').toString())
const getOrCreateMeta = async (name: string) => {
const instanceMetaFile = metaPath(name)
const meta = await (async () => {
if (!existsSync(instanceMetaFile)) {
const meta = await mkInstance(name)
writeFileSync(instanceMetaFile, JSON.stringify(meta, null, 2))
}
const meta = readMeta(instanceMetaFile)
return meta
})()
return meta
}
const writeMeta = async (instance: InstanceFields) => {
const { subdomain } = instance
const instanceMetaFile = metaPath(subdomain)
writeFileSync(instanceMetaFile, JSON.stringify(instance, null, 2))
}
const plugin: PocketHostPlugin = async ({}) => {
dbg(`initializing ${PLUGIN_NAME}`)
/** Display some informational alerts to help the user get started. */
onAfterServerStartAction(async () => {
const protocol = PORT() === 443 ? 'https' : 'http'
{
const url = new URL(`${protocol}://*.${APEX_DOMAIN()}`)
url.port = `${PORT() === 80 || PORT() == 443 ? '' : PORT()}`
info(`Listening for requests on ${url}`)
}
{
const url = new URL(`${protocol}://hello.${APEX_DOMAIN()}`)
url.port = `${PORT() === 80 || PORT() == 443 ? '' : PORT()}`
info(`Try visiting ${url}`)
}
})
/** When a request comes in, return an instance based on subdomain */
onGetInstanceByRequestInfoFilter(async (instance, context) => {
const { subdomain } = context
const meta = await getOrCreateMeta(subdomain)
return { ...instance, ...meta }
})
/**
* When a new instance model is instantiated, this filter gives listeners a
* chance to augment or update the instance data.
*
* In this case, the instance data is restored from a local db.
*/
onNewInstanceRecordFilter(async (instance) => {
const { subdomain } = instance
const path = metaPath(subdomain)
if (!existsSync(path)) return instance
const meta = await readMeta(path)
return { ...instance, ...meta }
})
/** After an instance has been found, store it to the db */
onAfterInstanceFoundAction(async (context) => {
const { instance } = context
await writeMeta(instance)
})
const instances: { [_: string]: Promise<string> } = {}
const launchMutex = new Mutex()
/**
* The workhorse. This filter is responsible for launching PocketBase and
* returning an endpoint URL.
*/
onGetOrProvisionInstanceUrlFilter(async (url: string, { instance }) => {
const { dev, subdomain, version, secrets } = instance
if (subdomain in instances) return instances[subdomain]!
dbg({ instance })
return (instances[subdomain] = new Promise<string>(
async (resolve, reject) => {
const bot = await gobot(`pocketbase`, { version })
const realVersion = await bot.maxSatisfyingVersion(version)
if (!realVersion) {
throw new Error(`No PocketBase version satisfying ${version}`)
}
const instanceConfig = await doInstanceConfigFilter({
env: {},
binds: {
data: [],
hooks: [],
migrations: [],
public: [],
},
})
dbg(`instanceConfig`, { instanceConfig })
const dataDir = INSTANCE_DATA_DIR(subdomain, `pb_data`)
const hooksDir = INSTANCE_DATA_DIR(subdomain, `pb_hooks`)
const migrationsDir = INSTANCE_DATA_DIR(subdomain, `pb_migrations`)
const publicDir = INSTANCE_DATA_DIR(subdomain, `pb_public`)
;[dataDir, hooksDir, migrationsDir, publicDir].forEach((dir) => {
return mkdirSync(dir, { recursive: true })
})
const { binds, env } = instanceConfig
copyFiles(binds.data, dataDir)
copyFiles(binds.hooks, hooksDir)
copyFiles(binds.migrations, migrationsDir)
copyFiles(binds.public, publicDir)
return launchMutex.runExclusive(async () => {
dbg(`got lock`)
const port = await getPort()
const args = [
`serve`,
`--dir`,
escape(dataDir),
`--hooksDir`,
escape(hooksDir),
`--migrationsDir`,
escape(migrationsDir),
`--publicDir`,
escape(publicDir),
`--http`,
`0.0.0.0:${port}`,
]
if (dev && gte(realVersion, `0.20.1`)) args.push(`--dev`)
doInstanceLogAction({
instance,
type: 'stdout',
data: `Launching: ${await bot.getBinaryFilePath()} ${args.join(
' ',
)}`,
})
bot.run(args, { env: { ...secrets, ...env } }, (proc) => {
proc.stdout.on('data', (data) => {
data
.toString()
.trim()
.split(`\n`)
.forEach((line: string) => {
doInstanceLogAction({
instance,
type: 'stdout',
data: line,
})
})
})
proc.stderr.on('data', (data) => {
data
.toString()
.trim()
.split(`\n`)
.forEach((line: string) => {
doInstanceLogAction({
instance,
type: 'stderr',
data: line,
})
})
})
const unsub = exitHook(() => {
dbg(`killing ${subdomain}`)
doInstanceLogAction({
instance,
type: 'stdout',
data: `Forcibly killing PocketBase process`,
})
proc.kill()
})
proc.on('exit', (code) => {
unsub()
delete instances[subdomain]
doInstanceLogAction({
instance,
type: 'stdout',
data: `PocketBase process exited with code ${code}`,
})
doAfterInstanceStoppedAction({ instance, url })
dbg(`${subdomain} process exited with code ${code}`)
})
const url = `http://localhost:${port}`
doInstanceLogAction({
instance,
type: 'stdout',
data: `Waiting for PocketBase to start on ${url}`,
})
tryFetch(url)
.then(() => {
doInstanceLogAction({
instance,
type: 'stdout',
data: `PocketBase started on ${url}`,
})
doAfterInstanceStartedAction({ instance, url })
return resolve(url)
})
.catch((e) => {
doInstanceLogAction({
instance,
type: 'stderr',
data: `PocketBase failed to start on ${url}: ${e}`,
})
reject(e)
})
})
})
},
))
})
}
import { plugin } from './plugin'
export default plugin

View File

@ -0,0 +1,283 @@
import { Mutex } from 'async-mutex'
import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import getPort from 'get-port'
import { globSync } from 'glob'
import { gobot } from 'gobot'
import path from 'path'
import {
Bind,
InstanceFields,
PocketHostPlugin,
doAfterInstanceStartedAction,
doAfterInstanceStoppedAction,
doInstanceConfigFilter,
doInstanceLogAction,
mkInstance,
onAfterInstanceFoundAction,
onAfterServerStartAction,
onGetInstanceByRequestInfoFilter,
onGetOrProvisionInstanceUrlFilter,
onNewInstanceRecordFilter,
} from 'pockethost'
import {
APEX_DOMAIN,
INSTANCE_DATA_DIR,
PORT,
exitHook,
tryFetch,
} from 'pockethost/core'
import { gte } from 'semver'
import { PLUGIN_NAME } from './constants'
import { dbg, info } from './log'
const deleteFiles = (globPattern: string) => {
const files = globSync(globPattern)
files.forEach((file) => {
dbg(`Deleting ${file}`)
fs.unlinkSync(file)
})
}
const copyFiles = (binds: Bind[], destination: string) => {
binds.forEach((bind) => {
const srcFiles = globSync(bind.src)
srcFiles.forEach((srcFile) => {
const relativePath = path.relative(bind.base, srcFile)
const destFile = path.join(destination, relativePath)
const destDir = path.dirname(destFile)
dbg(`Copying ${srcFile} to ${destFile}`, { relativePath, destDir, bind })
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
fs.copyFileSync(srcFile, destFile)
})
})
}
const escape = (path: string) => `"${path}"`
const metaPath = (name: string) => {
const instanceRootDir = INSTANCE_DATA_DIR(name)
mkdirSync(instanceRootDir, { recursive: true })
const instanceMetaFile = INSTANCE_DATA_DIR(name, `meta.json`)
return instanceMetaFile
}
const readMeta = (path: string) =>
JSON.parse(readFileSync(path, 'utf-8').toString())
const getOrCreateMeta = async (name: string) => {
const instanceMetaFile = metaPath(name)
const meta = await (async () => {
if (!existsSync(instanceMetaFile)) {
const meta = await mkInstance(name)
writeFileSync(instanceMetaFile, JSON.stringify(meta, null, 2))
}
const meta = readMeta(instanceMetaFile)
return meta
})()
return meta
}
const writeMeta = async (instance: InstanceFields) => {
const { subdomain } = instance
const instanceMetaFile = metaPath(subdomain)
writeFileSync(instanceMetaFile, JSON.stringify(instance, null, 2))
}
export const plugin: PocketHostPlugin = async ({}) => {
dbg(`initializing ${PLUGIN_NAME}`)
/** Display some informational alerts to help the user get started. */
onAfterServerStartAction(async () => {
const protocol = PORT() === 443 ? 'https' : 'http'
{
const url = new URL(`${protocol}://*.${APEX_DOMAIN()}`)
url.port = `${PORT() === 80 || PORT() == 443 ? '' : PORT()}`
info(`Listening for requests on ${url}`)
}
{
const url = new URL(`${protocol}://hello.${APEX_DOMAIN()}`)
url.port = `${PORT() === 80 || PORT() == 443 ? '' : PORT()}`
info(`Try visiting ${url}`)
}
})
/** When a request comes in, return an instance based on subdomain */
onGetInstanceByRequestInfoFilter(async (instance, context) => {
const { subdomain } = context
const meta = await getOrCreateMeta(subdomain)
return { ...instance, ...meta }
})
/**
* When a new instance model is instantiated, this filter gives listeners a
* chance to augment or update the instance data.
*
* In this case, the instance data is restored from a local db.
*/
onNewInstanceRecordFilter(async (instance) => {
const { subdomain } = instance
const path = metaPath(subdomain)
if (!existsSync(path)) return instance
const meta = await readMeta(path)
return { ...instance, ...meta }
})
/** After an instance has been found, store it to the db */
onAfterInstanceFoundAction(async (context) => {
const { instance } = context
await writeMeta(instance)
})
const instances: { [_: string]: Promise<string> } = {}
const launchMutex = new Mutex()
/**
* The workhorse. This filter is responsible for launching PocketBase and
* returning an endpoint URL.
*/
onGetOrProvisionInstanceUrlFilter(async (url: string, { instance }) => {
const { dev, subdomain, version, secrets } = instance
if (subdomain in instances) return instances[subdomain]!
dbg({ instance })
return (instances[subdomain] = new Promise<string>(
async (resolve, reject) => {
const bot = await gobot(`pocketbase`, { version })
const realVersion = await bot.maxSatisfyingVersion(version)
if (!realVersion) {
throw new Error(`No PocketBase version satisfying ${version}`)
}
const instanceConfig = await doInstanceConfigFilter({
env: {},
binds: {
data: [],
hooks: [],
migrations: [],
public: [],
},
})
dbg(`instanceConfig`, { instanceConfig })
const dataDir = INSTANCE_DATA_DIR(subdomain, `pb_data`)
const hooksDir = INSTANCE_DATA_DIR(subdomain, `pb_hooks`)
const migrationsDir = INSTANCE_DATA_DIR(subdomain, `pb_migrations`)
const publicDir = INSTANCE_DATA_DIR(subdomain, `pb_public`)
;[dataDir, hooksDir, migrationsDir, publicDir].forEach((dir) => {
return mkdirSync(dir, { recursive: true })
})
const { binds, env } = instanceConfig
copyFiles(binds.data, dataDir)
copyFiles(binds.hooks, hooksDir)
copyFiles(binds.migrations, migrationsDir)
copyFiles(binds.public, publicDir)
return launchMutex.runExclusive(async () => {
dbg(`got lock`)
const port = await getPort()
const args = [
`serve`,
`--dir`,
escape(dataDir),
`--hooksDir`,
escape(hooksDir),
`--migrationsDir`,
escape(migrationsDir),
`--publicDir`,
escape(publicDir),
`--http`,
`0.0.0.0:${port}`,
]
if (dev && gte(realVersion, `0.20.1`)) args.push(`--dev`)
doInstanceLogAction({
instance,
type: 'stdout',
data: `Launching: ${await bot.getBinaryFilePath()} ${args.join(
' ',
)}`,
})
bot.run(args, { env: { ...secrets, ...env } }, (proc) => {
proc.stdout.on('data', (data) => {
data
.toString()
.trim()
.split(`\n`)
.forEach((line: string) => {
doInstanceLogAction({
instance,
type: 'stdout',
data: line,
})
})
})
proc.stderr.on('data', (data) => {
data
.toString()
.trim()
.split(`\n`)
.forEach((line: string) => {
doInstanceLogAction({
instance,
type: 'stderr',
data: line,
})
})
})
const unsub = exitHook(() => {
dbg(`killing ${subdomain}`)
doInstanceLogAction({
instance,
type: 'stdout',
data: `Forcibly killing PocketBase process`,
})
proc.kill()
})
proc.on('exit', (code) => {
unsub()
delete instances[subdomain]
doInstanceLogAction({
instance,
type: 'stdout',
data: `PocketBase process exited with code ${code}`,
})
doAfterInstanceStoppedAction({ instance, url })
dbg(`${subdomain} process exited with code ${code}`)
})
const url = `http://localhost:${port}`
doInstanceLogAction({
instance,
type: 'stdout',
data: `Waiting for PocketBase to start on ${url}`,
})
tryFetch(url)
.then(() => {
doInstanceLogAction({
instance,
type: 'stdout',
data: `PocketBase started on ${url}`,
})
doAfterInstanceStartedAction({ instance, url })
return resolve(url)
})
.catch((e) => {
doInstanceLogAction({
instance,
type: 'stderr',
data: `PocketBase failed to start on ${url}: ${e}`,
})
reject(e)
})
})
})
},
))
})
}