pockethost 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # pockethost
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 40bbb3c: Improve tryFetch by accepting node-fetch params
8
+ - 12f029a: Add changeset maker
9
+ - b7277cf: Add public instance URL calculators and refactor HTTP protocol detection
10
+ - 45a8f45: Add internal app (remote control) support
11
+ - 55b43a1: Remove unneeded params from GetOrProvisionInstanceUrl filter
12
+ - 5be2c9e: Initial commit
13
+ - a236f3e: Added several more filters
14
+ - 35edc77: Enhancement: actions execute sequentially
15
+ - 879845a: Enhancement: unsubscribe from filters
16
+ - c473ed9: Added support for priorities in actions
17
+ - 795fcd7: New action: KillInstance
18
+ - c32b845: Added support for Settings filter whereby all settings are collected and displayed in DEBUG() mode
19
+ - cce72b3: New action: AfterPluginsLoaded
20
+ - 82dde2d: Enhancement: filters now have extensible data types
21
+
22
+ ### Patch Changes
23
+
24
+ - b55c585: Clean up dead code
25
+ - 0e4bf43: Updated commit parser deps
26
+ - f79b067: Added serve command details
27
+ - dd3ceaa: Added filter debugging
28
+ - 7931204: Fixed a bug where adding plugins was not saved correctly
29
+ - 6e14f57: Updated plugin authoring guide
30
+ - 7604930: Minor fix to GetOrProvisionInstanceUrl filter
31
+ - 52b70cb: Fixed bug where --extra-plugins was not defaulting to '' correctly
32
+ - Updated dependencies [c32b845]
33
+ - @pockethost/plugin-launcher-spawn@1.0.0
34
+ - @pockethost/plugin-auto-admin@1.0.0
35
+ - @pockethost/plugin-console-logger@1.0.0
36
+
3
37
  ## 1.5.0
4
38
 
5
39
  ### Minor Changes
package/changesetx.ts ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env tsx
2
+ import { execSync } from 'child_process'
3
+ import { sync } from 'conventional-commits-parser'
4
+ import fs, { readFileSync } from 'fs'
5
+ import minimist from 'minimist'
6
+ import { dirname, join } from 'path'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url))
10
+ const repoRootDir = join(__dirname, `../..`)
11
+
12
+ const argv = minimist(process.argv.slice(2))
13
+ const commitMessage = argv._[0]!
14
+ const { type, subject } = sync(commitMessage)
15
+ if (!type) {
16
+ console.log('Invalid commit message type')
17
+ process.exit(1)
18
+ }
19
+
20
+ function getStagedPackageNames(filePaths: string[]): string[] {
21
+ const packageNames = new Set<string>()
22
+
23
+ filePaths.forEach((filePath) => {
24
+ const packageName = getPackageName(filePath)
25
+ if (packageName) {
26
+ packageNames.add(packageName)
27
+ }
28
+ })
29
+
30
+ return Array.from(packageNames)
31
+ }
32
+
33
+ function getPackageName(filePath: string): string | null {
34
+ const packagePath = filePath.split('/').slice(0, 2).join('/')
35
+ const packageJsonPath = join(repoRootDir, `${packagePath}/package.json`)
36
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
37
+ try {
38
+ return packageJson.name
39
+ } catch (error) {
40
+ return null
41
+ }
42
+ }
43
+
44
+ function getStagedFileNames(): string[] {
45
+ const output = execSync('git diff --name-only --cached').toString()
46
+ const fileNames = output
47
+ .trim()
48
+ .split('\n')
49
+ .filter((fileName) => fileName.startsWith(`packages/`))
50
+ return fileNames
51
+ }
52
+
53
+ async function createChangesetFile() {
54
+ const fileNames = getStagedFileNames()
55
+ const packageNames = getStagedPackageNames(fileNames)
56
+
57
+ if (packageNames.length === 0) {
58
+ console.log('No packages staged for commit')
59
+ process.exit(0)
60
+ }
61
+ console.log('Staged packages:', packageNames)
62
+
63
+ const prefixMap = {
64
+ feat: 'minor',
65
+ enh: 'minor',
66
+ fix: 'patch',
67
+ docs: 'patch',
68
+ chore: 'patch',
69
+ } as const
70
+
71
+ if (!(type! in prefixMap)) {
72
+ console.log('Invalid prefix found in commit message')
73
+ process.exit(1)
74
+ }
75
+
76
+ const versionType = prefixMap[type as keyof typeof prefixMap]
77
+
78
+ const changesetFile = [
79
+ `---`,
80
+ ...packageNames.map((packageName) => `'${packageName}': ${versionType}`),
81
+ `---`,
82
+ ``,
83
+ subject,
84
+ ].join('\n')
85
+ return changesetFile
86
+ }
87
+
88
+ async function writeChangesetFile(changesetFile: string) {
89
+ const filePath = join(repoRootDir, `.changeset`, `${+new Date()}.md`)
90
+ try {
91
+ fs.writeFileSync(filePath, changesetFile)
92
+ console.log(`Changeset file written successfully at ${filePath}`)
93
+ execSync(`git add ${filePath}`)
94
+ console.log(`Changeset file added to git`)
95
+ } catch (error) {
96
+ console.error(`Error writing changeset file: ${error}`)
97
+ }
98
+ }
99
+
100
+ function commit() {
101
+ execSync(`git commit -m "${commitMessage}"`)
102
+ }
103
+
104
+ async function main() {
105
+ const changesetFile = await createChangesetFile()
106
+ await writeChangesetFile(changesetFile)
107
+ commit()
108
+ }
109
+
110
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pockethost",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "author": {
5
5
  "name": "Ben Allfree",
6
6
  "url": "https://github.com/benallfree"
@@ -41,19 +41,22 @@
41
41
  "typescript": "^5.4.5",
42
42
  "update-notifier": "^7.0.0",
43
43
  "url-pattern": "^1.0.3",
44
- "@pockethost/plugin-auto-admin": "^0.1.1",
45
- "@pockethost/plugin-console-logger": "^0.2.1",
46
- "@pockethost/plugin-launcher-spawn": "^0.3.0"
44
+ "@pockethost/plugin-launcher-spawn": "^0.4.0",
45
+ "@pockethost/plugin-console-logger": "^0.3.0",
46
+ "@pockethost/plugin-auto-admin": "^0.2.0"
47
47
  },
48
48
  "devDependencies": {
49
+ "@types/conventional-commits-parser": "^5.0.0",
49
50
  "@types/express": "^4.17.21",
50
51
  "@types/http-proxy": "^1.17.13",
52
+ "@types/inquirer": "^9.0.7",
51
53
  "@types/json-stringify-safe": "^5.0.2",
52
54
  "@types/minimist": "^1.2.5",
53
55
  "@types/node": "^20.8.10",
54
56
  "@types/semver": "^7.5.4",
55
57
  "@types/tmp": "^0.2.5",
56
- "@types/update-notifier": "^6.0.8"
58
+ "@types/update-notifier": "^6.0.8",
59
+ "conventional-commits-parser": "^5.0.0"
57
60
  },
58
61
  "scripts": {
59
62
  "check-types": "tsc --noEmit ",
package/readme.md CHANGED
@@ -10,21 +10,75 @@ Extend PocketHost with its Plugin ecosystem. Start with the bare-bones dev serve
10
10
 
11
11
  ---
12
12
 
13
+ <!-- @import "[TOC]" {cmd="toc" depthFrom=2 depthTo=2 orderedList=false} -->
14
+
15
+ <!-- code_chunk_output -->
16
+
17
+ - [Quickstart](#quickstart)
18
+ - [Introduction](#introduction)
19
+ - [The magic `serve` command](#the-magic-serve-command)
20
+ - [Configuration](#configuration)
21
+ - [Plugins Directory](#plugins-directory)
22
+ - [Writing Plugins](#writing-plugins)
23
+ - [Support](#support)
24
+
25
+ <!-- /code_chunk_output -->
26
+
27
+ ---
28
+
13
29
  ## Quickstart
14
30
 
15
31
  ```
16
32
  npx pockethost serve
17
33
  ```
18
34
 
35
+ ## Introduction
36
+
37
+ PocketHost is a multitenant PocketBase hosting system. With it, you can run multiple instances of PocketBase on different subdomains, all on the same server.
38
+
39
+ Use PocketHost for development or production purposes.
40
+
41
+ PocketHost can be easily extended using its thoughtful plugin architecture. By choosing additional plugins, PocketHost's features can be extended according to your exact needs. You can even write your own plugins.
42
+
43
+ ## The magic `serve` command
44
+
45
+ `pockethost serve` is a special command that can be decorated with plugins. Plugins can respond to the `serve` command by running their own services.
46
+
47
+ By convention, plugins that have some kind of `serve` functionality such as running a server, will make it available in two ways.
48
+
49
+ The first way runs ONLY the plugin's `serve` functionality:
50
+
51
+ ```bash
52
+ pockethost <plugin-name> serve
53
+ ```
54
+
55
+ The second way runs ALL `serve` functionality from ALL plugins:
56
+
57
+ ```bash
58
+ pockethost serve
59
+ ```
60
+
61
+ The exact plugins can also be narrowed:
62
+
63
+ ```bash
64
+ pockethost serve --only=plugin1,plugin2,plugin3
65
+ ```
66
+
67
+ See the plugin authoring guide for more information on how to make a plugin that responds to both methods.
68
+
19
69
  ## Configuration
20
70
 
21
- PocketHost supports configuration by environment variable. All PocketHost environment variables begin with `PH_`.
71
+ PocketHost supports configuration by environment variable, `.env` file, and also built-in settings via `pockethost config`.
72
+
73
+ All PocketHost variables begin with `PH_`.
22
74
 
23
75
  PocketHost will search the current directory for an `.env` when it loads. Anything in `.env` will be merged with and override any `<PH_HOME>/.env` that may exist.
24
76
 
25
- Use `pockethost config` to set global defaults stored in `<PH_HOME>/.env`.
77
+ Use `pockethost config` to list and set global defaults.
26
78
 
27
- ### Variables
79
+ ### Core Variables
80
+
81
+ See `pockethost config ls` for a full list of variables. Plugin variables will also be listed for loaded plugins.
28
82
 
29
83
  | Name | Default | Discussion |
30
84
  | -------------- | -------------------------- | ----------------------------------------------------------------------------- |
@@ -42,29 +96,32 @@ Use `pockethost config` to set global defaults stored in `<PH_HOME>/.env`.
42
96
  - `@pockethost/plugin-launcher-spawn`
43
97
  - `@pockethost/plugin-auto-admin`
44
98
 
45
- ## Plugins
99
+ ## Plugins Directory
100
+
101
+ PocketHost uses an action/filter architecture to support a growing list plugins. Some plugins have a CLI component, whereas others add purely programmatic support to alter how PocketHost works.
46
102
 
47
- PocketHost uses a hook architecture and features a growing list plugins. Some plugins have a CLI component, whereas others add purely programmatic support to alter how PocketHost works:
103
+ ### Core plugins
48
104
 
49
- Current plugins:
105
+ These plugins are maintained by the PocketHost project.
50
106
 
51
- | name | description |
52
- | -------------------------------- | ------------------------------------------------------------------------------------------------- |
53
- | plugin-console-logger | Routes log messages to the console |
54
- | plugin-auto-admin | Auto-creates a default admin account on instance launch |
55
- | plugin-launcher-spawn | Launches PocketBase instances via `spawn()` on the local machine. PocketHost uses this by default |
56
- | plugin-cloudflare-request-logger | Log additional data when running behind Cloudflare |
107
+ | Name | Description |
108
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
109
+ | `@pockethost/plugin-console-logger` | Routes log messages to the console |
110
+ | `@pockethost/plugin-auto-admin` | Auto-creates a default admin account on instance launch |
111
+ | `@pockethost/plugin-launcher-spawn` | Launches PocketBase instances via `spawn()` on the local machine. PocketHost uses this by default |
112
+ | `@pockethost/plugin-cloudflare-request-logger` | Log additional data when running behind Cloudflare |
113
+ | `@pockethost/plugin-ftp-server` | Used to provide secure FTPS access to PocketBase instance data. This FTP server can run along-side a PocketHost server. |
57
114
 
58
115
  Coming soon:
59
116
  | name | description |
60
117
  | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
61
- | plugin-ftp-server | Used to provide secure FTPS access to PocketBase instance data. This FTP server can run along-side a PocketHost server. |
62
118
  | plugin-launcher-docker | Launches PocketBase instances via Docker. This is useful for running untrusted PocketBase code and instances. pockethost.io uses this. |
63
119
  | plugin-waf | A Web Application Firewall |
64
- | plugin-waf-enforce-ssl | Enforce SSL at the
65
- WAF
120
+ | plugin-waf-enforce-ssl | Enforce SSL at the WAF
121
+
122
+ ## Writing Plugins
66
123
 
67
- See the [Plugin Authoring Guide](https://github.com/pockethost/pockethost/blob/master/packages/pockethost/plugin-guide.md) for details about what hooks and filters are available.
124
+ See the [Plugin Authoring Guide](https://github.com/pockethost/pockethost/blob/master/packages/pockethost/plugin-guide.md) for details about how to write your own plugins.
68
125
 
69
126
  ## Support
70
127
 
@@ -1,20 +1,5 @@
1
- import { forEach, keys, omit } from '@s-libs/micro-dash'
2
1
  import { Command } from 'commander'
3
- import { parse } from 'dotenv'
4
- import { existsSync, readFileSync, writeFileSync } from 'fs'
5
- import { info } from '../..'
6
- import { PH_HOME, isSettingKey, settings } from '../../../constants'
7
-
8
- const envFile = () => {
9
- const envFile = PH_HOME(`.env`)
10
- if (!existsSync(envFile)) {
11
- writeFileSync(envFile, '')
12
- }
13
- return envFile
14
- }
15
-
16
- const _parse = () =>
17
- parse(readFileSync(envFile(), { encoding: 'utf8' }).toString())
2
+ import { listConfig, setConfig, unsetConfig } from '../../../core'
18
3
 
19
4
  export const ConfigCommand = () => {
20
5
  const cmd = new Command(`config`)
@@ -24,76 +9,20 @@ export const ConfigCommand = () => {
24
9
  .argument(`<name>`, `Config name`)
25
10
  .argument(`<value>`, `Config value`)
26
11
  .description(`Set a config value`)
27
- .action((name, value) => {
28
- if (!isSettingKey(name))
29
- throw new Error(`Invalid setting name ${name}`)
30
-
31
- if (value === '=') throw new Error(`Invalid value ${value}`)
32
-
33
- const values = _parse()
34
- values[name] = value
35
- writeFileSync(
36
- envFile(),
37
- Object.entries(values)
38
- .map(([k, v]) => `${k}=${v}`)
39
- .join('\n'),
40
- )
41
- info(`Set ${name}=${value}`)
42
- info(`Written to ${envFile()}`)
43
- }),
12
+ .action(setConfig),
44
13
  )
45
14
 
46
15
  .addCommand(
47
16
  new Command(`unset`)
48
17
  .argument(`<name>`, `Config name`)
49
18
  .description(`Unset a config value`)
50
- .action((name) => {
51
- if (!isSettingKey(name))
52
- throw new Error(`Invalid setting name ${name}`)
53
-
54
- const values = _parse()
55
- delete values[name]
56
- writeFileSync(
57
- envFile(),
58
- Object.entries(values)
59
- .map(([k, v]) => `${k}=${v}`)
60
- .join('\n'),
61
- )
62
- info(`Unset ${name}`)
63
- info(`Written to ${envFile()}`)
64
- }),
19
+ .action(unsetConfig),
65
20
  )
66
21
  .addCommand(
67
22
  new Command(`list`)
68
23
  .description(`List all config values`)
69
24
  .alias(`ls`)
70
- .action(() => {
71
- const values = _parse()
72
-
73
- if (keys(values).length > 0) {
74
- info()
75
- info(`Config values from ${envFile()}:`)
76
- forEach(values, (v, k) => {
77
- info(`\t${k}=${v}`)
78
- })
79
- info()
80
- } else {
81
- info(`No config values found in ${envFile()}`)
82
- }
83
-
84
- const defaults = omit(settings, keys(values) as any)
85
- if (keys(defaults).length > 0) {
86
- info(`Default values:`)
87
- forEach(settings, (v, k) => {
88
- if (k in values) return
89
- info(`\t${k}=${v}`)
90
- })
91
- } else {
92
- info(
93
- `No default values because all values are defined in ${envFile()}`,
94
- )
95
- }
96
- }),
25
+ .action(listConfig),
97
26
  )
98
27
  return cmd
99
28
  }
@@ -1,22 +1,10 @@
1
- import { uniq } from '@s-libs/micro-dash'
2
1
  import { Command } from 'commander'
3
- import { parse } from 'dotenv'
4
- import { existsSync, readFileSync, writeFileSync } from 'fs'
2
+ import { readFileSync } from 'fs'
5
3
  import { error, info } from '../..'
6
- import { PH_HOME, PH_PLUGINS, PH_PROJECT_DIR } from '../../../constants'
4
+ import { PH_PLUGINS, PH_PROJECT_DIR } from '../../../constants'
5
+ import { appendConfig, filterConfig } from '../../../core'
7
6
  import { getCompatibleVersions, getPackageJson, removePackage } from './util'
8
7
 
9
- const envFile = () => {
10
- const envFile = PH_HOME(`.env`)
11
- if (!existsSync(envFile)) {
12
- writeFileSync(envFile, '')
13
- }
14
- return envFile
15
- }
16
-
17
- const _parse = () =>
18
- parse(readFileSync(envFile(), { encoding: 'utf8' }).toString())
19
-
20
8
  export const PluginCommand = () => {
21
9
  const cmd = new Command(`plugin`)
22
10
  .description(`Manage PocketHost plugins`)
@@ -33,22 +21,7 @@ export const PluginCommand = () => {
33
21
  const { version } = pkg
34
22
  await getCompatibleVersions(`pockethost`, version, [name])
35
23
 
36
- const values = _parse()
37
- values[`PH_PLUGINS`] = uniq([
38
- ...(values[`PH_PLUGINS`]
39
- ?.split(/,/)
40
- .map((v) => v.trim())
41
- .filter((v) => !!v) || []),
42
- name,
43
- ]).join(`,`)
44
- writeFileSync(
45
- envFile(),
46
- Object.entries(values)
47
- .map(([k, v]) => `${k}=${v}`)
48
- .join('\n'),
49
- )
50
- info(`Installed ${name}`)
51
- info(`Written to ${envFile()}`)
24
+ appendConfig(`PH_PLUGINS`, name)
52
25
  } catch (e) {
53
26
  error(`${e}`)
54
27
  }
@@ -81,21 +54,7 @@ export const PluginCommand = () => {
81
54
  .action(async (name) => {
82
55
  try {
83
56
  await removePackage(name)
84
- const values = _parse()
85
- values[`PH_PLUGINS`] = uniq([
86
- ...(values[`PH_PLUGINS`]
87
- ?.split(/,/)
88
- .map((v) => v.trim())
89
- .filter((v) => v !== name) || []),
90
- ]).join(`,`)
91
- writeFileSync(
92
- envFile(),
93
- Object.entries(values)
94
- .map(([k, v]) => `${k}=${v}`)
95
- .join('\n'),
96
- )
97
- info(`Removed ${name}`)
98
- info(`Written to ${envFile()}`)
57
+ filterConfig(`PH_PLUGINS`, name)
99
58
  } catch (e) {
100
59
  error(`${e}`)
101
60
  }
package/src/cli/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  DEBUG,
11
11
  LogLevelName,
12
12
  LoggerService,
13
+ doAfterPluginsLoadedAction,
13
14
  doCliCommandsFilter,
14
15
  loadPlugins,
15
16
  } from '../common'
@@ -36,13 +37,17 @@ export const main = async () => {
36
37
 
37
38
  const argv = minimist<{ e: string }>(process.argv.slice(2), {
38
39
  alias: { e: 'extra-plugins' },
40
+ default: { e: '' },
39
41
  })
40
42
  const extraPlugins = argv.e
41
43
  .split(/,/)
42
44
  .map((s) => s.trim())
43
45
  .filter((v) => !!v)
46
+
44
47
  await loadPlugins([pockethost, ...uniq(PH_PLUGINS()), ...extraPlugins])
45
48
 
49
+ await doAfterPluginsLoadedAction()
50
+
46
51
  program
47
52
  .name('pockethost')
48
53
  .description('Multitenant PocketBase hosting')
@@ -1,9 +1,12 @@
1
+ import { uniqueId } from '@s-libs/micro-dash'
1
2
  import { Express, Request, Response } from 'express'
3
+ import { dbg } from '../../cli'
2
4
  import { LogLevelName } from '../Logger'
3
5
  import { DEBUG } from '../debug'
4
6
  import { InstanceFields } from '../schema'
5
7
 
6
8
  enum CoreActions {
9
+ AfterPluginsLoaded = 'core_after_plugins_loaded',
7
10
  Init = 'core_init',
8
11
  Serve = 'core_serve',
9
12
  AppMounted = 'core_on_app_mount',
@@ -18,6 +21,8 @@ enum CoreActions {
18
21
  BeforeInstanceStarted = 'core_before_instance_started',
19
22
  AfterInstanceStarted = 'core_after_instance_started',
20
23
 
24
+ KillInstance = 'core_kill_instance',
25
+
21
26
  BeforeInstanceStopped = 'core_before_instance_stopped',
22
27
  AfterInstanceStopped = 'core_after_instance_stopped',
23
28
 
@@ -27,20 +32,34 @@ enum CoreActions {
27
32
  Request = 'core_edge_request',
28
33
  }
29
34
 
30
- const actions: {
31
- [key: string]: ActionHandler<any>[]
32
- } = {}
33
-
34
35
  export type ActionHandler<TContext = {}> = (
35
36
  context: TContext extends never ? [] : TContext,
36
37
  ) => Promise<void>
37
38
 
38
- async function registerAction(
39
+ interface Action {
40
+ priority: number
41
+ handler: ActionHandler<any>
42
+ }
43
+
44
+ const actions: {
45
+ [key: string]: Action[]
46
+ } = {}
47
+
48
+ function registerAction(
39
49
  action: string,
40
50
  handler: (...args: any[]) => Promise<void>,
41
- ) {
51
+ priority: number = 10,
52
+ ): () => void {
42
53
  if (!(action in actions)) actions[action] = []
43
- actions[action]!.push(handler)
54
+ const uid = uniqueId(`a:${action}:`)
55
+ dbg(`${uid}:subscribe`)
56
+ actions[action]!.push({ priority, handler })
57
+ actions[action]!.sort((a, b) => a.priority - b.priority)
58
+
59
+ return () => {
60
+ dbg(`${uid}:unsubscribe`)
61
+ actions[action] = actions[action]!.filter((a) => a.handler !== handler)
62
+ }
44
63
  }
45
64
 
46
65
  async function action(actionName: string, context: any) {
@@ -49,7 +68,9 @@ async function action(actionName: string, context: any) {
49
68
  }
50
69
  const action = actions[actionName]
51
70
  if (!action) return false
52
- await Promise.all(action.map((handler) => handler(context)))
71
+ for (const { handler } of action) {
72
+ await handler(context)
73
+ }
53
74
  return true
54
75
  }
55
76
 
@@ -58,18 +79,22 @@ export function createCustomActionWithContext<TContext extends {}>(
58
79
  ) {
59
80
  return [
60
81
  async (context: TContext) => action(actionName, context),
61
- async (handler: ActionHandler<TContext>) =>
62
- registerAction(actionName, handler),
82
+ (handler: ActionHandler<TContext>, priority = 10) =>
83
+ registerAction(actionName, handler, priority),
63
84
  ] as const
64
85
  }
65
86
 
66
87
  export function createCustomAction(actionName: CoreActions) {
67
88
  return [
68
89
  async () => action(actionName, {}),
69
- async (handler: ActionHandler) => registerAction(actionName, handler),
90
+ (handler: ActionHandler, priority = 10) =>
91
+ registerAction(actionName, handler, priority),
70
92
  ] as const
71
93
  }
72
94
 
95
+ export const [doAfterPluginsLoadedAction, onAfterPluginsLoadedAction] =
96
+ createCustomAction(CoreActions.AfterPluginsLoaded)
97
+
73
98
  export const [doRequestErrorAction, onRequestErrorAction] =
74
99
  createCustomActionWithContext<{
75
100
  err: Error
@@ -92,7 +117,9 @@ export const [doInstanceLogAction, onInstanceLogAction] =
92
117
  }>(CoreActions.InstanceLog)
93
118
 
94
119
  export const [doAppMountedAction, onAppMountedAction] =
95
- createCustomActionWithContext<{ app: Express }>(CoreActions.AppMounted)
120
+ createCustomActionWithContext<{ app: Express; internalApp: Express }>(
121
+ CoreActions.AppMounted,
122
+ )
96
123
 
97
124
  export const [doIncomingRequestAction, onIncomingRequestAction] =
98
125
  createCustomActionWithContext<{ req: Request; res: Response; host: string }>(
@@ -117,6 +144,11 @@ export const [doAfterInstanceStartedAction, onAfterInstanceStartedAction] =
117
144
  CoreActions.AfterInstanceStarted,
118
145
  )
119
146
 
147
+ export const [doKillInstanceAction, onKillInstanceAction] =
148
+ createCustomActionWithContext<{ instance: InstanceFields }>(
149
+ CoreActions.KillInstance,
150
+ )
151
+
120
152
  export const [doBeforeInstanceStoppedAction, onBeforeInstanceStoppedAction] =
121
153
  createCustomActionWithContext<{ instance: InstanceFields; url: string }>(
122
154
  CoreActions.BeforeInstanceStopped,
@@ -2,13 +2,18 @@ import { Command } from 'commander'
2
2
  import { Request, Response } from 'express'
3
3
  import { DEBUG } from '../debug'
4
4
  import { InstanceFields, InstanceId } from '../schema'
5
+ import { UserId } from '../schema/BaseFields'
5
6
 
6
7
  export enum CoreFilters {
8
+ Settings = 'core_settings',
7
9
  CliCommands = 'core_cli_commands',
8
10
  ServeSlugs = 'core_serve_slugs',
9
11
  SpawnConfig = 'core_spawn_config',
12
+ AuthenticateUser = 'core_authenticate_user',
10
13
  GetInstanceByRequestInfo = 'core_get_instance_by_request_info',
11
14
  GetInstanceById = 'core_get_instance_by_id',
15
+ GetOneInstanceByExactCriteria = 'core_get_one_instance_by_exact_criteria',
16
+ GetAllInstancesByExactCriteria = 'core_get_all_instances_by_exact_criteria',
12
17
  InstanceConfig = 'core_instance_config',
13
18
  ErrorSpawningInstanceMessage = 'core_error_spawning_instance_message',
14
19
  FailedToLaunchInstanceMessage = 'core_failed_to_launch_instance_message',
@@ -17,6 +22,7 @@ export enum CoreFilters {
17
22
  GetOrProvisionInstanceUrl = 'core_get_or_provision_instance_url',
18
23
  PocketBaseLaunchHandler = 'core_pocket_base_launch_handler',
19
24
  NewInstanceRecord = 'core_new_instance_record',
25
+ IsInstanceRunning = 'core_is_instance_running',
20
26
  }
21
27
 
22
28
  export type FilterHandler<TCarry, TContext> = (
@@ -31,14 +37,23 @@ const filters: {
31
37
  [key: string]: FilterEntry<any>[]
32
38
  } = {}
33
39
 
34
- async function registerFilter<TCarry, TContext extends {} = {}>(
40
+ function registerFilter<TCarry, TContext extends {} = {}>(
35
41
  filter: string,
36
42
  handler: FilterHandler<TCarry, TContext>,
37
43
  priority = 10,
38
- ) {
44
+ ): () => void {
39
45
  if (!(filter in filters)) filters[filter] = []
40
46
  filters[filter]!.push({ priority, handler })
41
47
  filters[filter]!.sort((a, b) => a.priority - b.priority)
48
+
49
+ return () => {
50
+ const index = filters[filter]!.findIndex(
51
+ (entry) => entry.handler === handler,
52
+ )
53
+ if (index !== -1) {
54
+ filters[filter]!.splice(index, 1)
55
+ }
56
+ }
42
57
  }
43
58
 
44
59
  async function filter<TCarry, TContext extends {} = {}>(
@@ -53,7 +68,14 @@ async function filter<TCarry, TContext extends {} = {}>(
53
68
  if (!filter) return initialValue
54
69
  return filter.reduce(
55
70
  (carry, entry) =>
56
- carry.then((carryRes) => entry.handler(carryRes, context)),
71
+ carry
72
+ .then((carryRes) => entry.handler(carryRes, context))
73
+ .then((carryRes) => {
74
+ if (DEBUG()) {
75
+ console.log(`f:${filterName}`, carryRes, context)
76
+ }
77
+ return carryRes
78
+ }),
57
79
  Promise.resolve(initialValue),
58
80
  ) as TCarry
59
81
  }
@@ -64,8 +86,15 @@ function createCustomFilterWithContext<TCarry, TContext extends {} = {}>(
64
86
  return [
65
87
  async (initialValue: TCarry, context: TContext) =>
66
88
  filter<TCarry, TContext>(filterName, initialValue, context),
67
- async (handler: FilterHandler<TCarry, TContext>, priority = 10) =>
68
- registerFilter<TCarry, TContext>(filterName, handler, priority),
89
+ async <TExtraCarry = unknown>(
90
+ handler: FilterHandler<TCarry & TExtraCarry, TContext>,
91
+ priority = 10,
92
+ ) =>
93
+ registerFilter<TCarry & TExtraCarry, TContext>(
94
+ filterName,
95
+ handler,
96
+ priority,
97
+ ),
69
98
  ] as const
70
99
  }
71
100
 
@@ -73,11 +102,24 @@ function createCustomFilter<TCarry>(filterName: string) {
73
102
  return [
74
103
  async (initialValue: TCarry) =>
75
104
  filter<TCarry>(filterName, initialValue, {}),
76
- async (handler: FilterHandler<TCarry, {}>, priority = 10) =>
77
- registerFilter<TCarry, {}>(filterName, handler, priority),
105
+ async <TExtraCarry = unknown>(
106
+ handler: FilterHandler<TCarry & TExtraCarry, {}>,
107
+ priority = 10,
108
+ ) =>
109
+ registerFilter<TCarry & TExtraCarry, {}>(filterName, handler, priority),
78
110
  ] as const
79
111
  }
80
112
 
113
+ export const [doSettingsFilter, onSettingsFilter] = createCustomFilter<{
114
+ [_: string]: any
115
+ }>(CoreFilters.Settings)
116
+
117
+ export const [doAuthenticateUserFilter, onAuthenticateUserFilter] =
118
+ createCustomFilterWithContext<
119
+ UserId | undefined,
120
+ { username: string; password: string }
121
+ >(CoreFilters.AuthenticateUser)
122
+
81
123
  export const [
82
124
  doGetInstanceByRequestInfoFilter,
83
125
  onGetInstanceByRequestInfoFilter,
@@ -92,6 +134,22 @@ export const [doGetInstanceByIdFilter, onGetInstanceByIdFilter] =
92
134
  { instanceId: InstanceId }
93
135
  >(CoreFilters.GetInstanceById)
94
136
 
137
+ export const [
138
+ doGetOneInstanceByExactCriteriaFilter,
139
+ onGetOneInstanceByExactCriteriaFilter,
140
+ ] = createCustomFilterWithContext<
141
+ InstanceFields | undefined,
142
+ Partial<InstanceFields & { [_: string]: string | number }>
143
+ >(CoreFilters.GetOneInstanceByExactCriteria)
144
+
145
+ export const [
146
+ doGetAllInstancesByExactCriteriaFilter,
147
+ onGetAllInstancesByExactCriteriaFilter,
148
+ ] = createCustomFilterWithContext<
149
+ InstanceFields[],
150
+ Partial<InstanceFields & { [_: string]: string | number }>
151
+ >(CoreFilters.GetAllInstancesByExactCriteria)
152
+
95
153
  export const [doCliCommandsFilter, onCliCommandsFilter] = createCustomFilter<
96
154
  Command[]
97
155
  >(CoreFilters.CliCommands)
@@ -106,12 +164,8 @@ export const [
106
164
  doGetOrProvisionInstanceUrlFilter,
107
165
  onGetOrProvisionInstanceUrlFilter,
108
166
  ] = createCustomFilterWithContext<
109
- string,
167
+ string | undefined,
110
168
  {
111
- req: Request
112
- res: Response
113
- host: string
114
- subdomain: string
115
169
  instance: InstanceFields
116
170
  }
117
171
  >(CoreFilters.GetOrProvisionInstanceUrl)
@@ -136,3 +190,8 @@ export type InstanceConfig = {
136
190
  }
137
191
  export const [doInstanceConfigFilter, onInstanceConfigFilter] =
138
192
  createCustomFilter<InstanceConfig>(CoreFilters.InstanceConfig)
193
+
194
+ export const [doIsInstanceRunningFilter, onIsInstanceRunningFilter] =
195
+ createCustomFilterWithContext<boolean, { instance: InstanceFields }>(
196
+ CoreFilters.IsInstanceRunning,
197
+ )
@@ -0,0 +1,10 @@
1
+ export type RecordId = string
2
+ export type UserId = RecordId
3
+ export type IsoDate = string
4
+ export type BaseFields = {
5
+ id: RecordId
6
+ created: IsoDate
7
+ updated: IsoDate
8
+ }
9
+
10
+ export type AnyField = { [_: string]: string | number }
@@ -1,4 +1,6 @@
1
1
  import { doNewInstanceRecordFilter } from '../plugin'
2
+ import { IsoDate } from './BaseFields'
3
+ import { newId, pocketNow } from './util'
2
4
 
3
5
  export type VersionId = string
4
6
 
@@ -21,16 +23,24 @@ export type InstanceSecretCollection = {
21
23
  }
22
24
 
23
25
  export type InstanceFields = {
26
+ id: string
24
27
  subdomain: string
25
28
  version: VersionId
26
29
  secrets: InstanceSecretCollection
27
30
  dev: boolean // Should instance run in --dev mode
31
+ created: IsoDate
32
+ updated: IsoDate
28
33
  }
29
34
 
30
- export const mkInstance = (subdomain: string) =>
31
- doNewInstanceRecordFilter({
35
+ export const mkInstance = (subdomain: string, id = newId()) => {
36
+ const now = pocketNow()
37
+ return doNewInstanceRecordFilter({
38
+ id,
32
39
  subdomain,
33
40
  version: '*',
34
41
  secrets: {},
35
42
  dev: false,
43
+ created: now,
44
+ updated: now,
36
45
  })
46
+ }
@@ -1 +1,7 @@
1
- export const pocketNow = () => new Date().toISOString()
1
+ import { customAlphabet } from 'nanoid'
2
+ import { IsoDate } from './BaseFields'
3
+
4
+ export const newId = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 10)
5
+ export const pocketNow = () => toPocketDate(new Date())
6
+ export const toPocketDate = (date: Date) => date.toISOString()
7
+ export const fromPocketDate = (iso: IsoDate) => new Date(iso)
package/src/constants.ts CHANGED
@@ -4,10 +4,9 @@ import { dirname, join } from 'path'
4
4
  import { cwd } from 'process'
5
5
  import { fileURLToPath } from 'url'
6
6
  import { mkBoolean, mkCsvString, mkNumber, mkPath, mkString } from '../core'
7
- import { InstanceId } from './common'
7
+ import { InstanceFields, InstanceId } from './common'
8
8
  import { DEBUG, IS_DEV } from './common/debug'
9
9
  import { Settings } from './core/Settings'
10
- import { logSettings } from './core/logSettings'
11
10
 
12
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
13
12
 
@@ -17,11 +16,7 @@ config({ path: [join(_PH_HOME, '.env'), join(cwd(), `.env`)] })
17
16
 
18
17
  export const _PH_PROJECT_DIR = join(__dirname, '..')
19
18
  export const _APEX_DOMAIN = process.env.APEX_DOMAIN || 'pockethost.lvh.me'
20
- export const _HTTP_PROTOCOL = process.env.HTTP_PROTOCOL || `https:`
21
19
 
22
- export function isSettingKey(key: string): key is keyof typeof settings {
23
- return key in settings
24
- }
25
20
  export const settings = Settings({
26
21
  PH_PLUGINS: mkCsvString([
27
22
  `@pockethost/plugin-console-logger`,
@@ -32,6 +27,7 @@ export const settings = Settings({
32
27
  PH_HOME: mkPath(_PH_HOME),
33
28
  PH_PROJECT_DIR: mkPath(_PH_PROJECT_DIR),
34
29
 
30
+ PH_INTERNAL_HOST: mkString('localhost'),
35
31
  PH_APEX_DOMAIN: mkString(_APEX_DOMAIN),
36
32
 
37
33
  PH_PORT: mkNumber(3000),
@@ -39,6 +35,7 @@ export const settings = Settings({
39
35
  PH_DATA_DIR: mkPath(join(_PH_HOME, 'data'), { create: true }),
40
36
  PH_DEV: mkBoolean(IS_DEV()),
41
37
  PH_DEBUG: mkBoolean(DEBUG()),
38
+ PH_INTERNAL_APP_SECRET: mkString(''),
42
39
  })
43
40
 
44
41
  /** Accessors */
@@ -57,7 +54,17 @@ export const DATA_DIR = (...paths: string[]) =>
57
54
  export const NODE_ENV = () => process.env.NODE_ENV
58
55
  export const INSTANCE_DATA_DIR = (id: InstanceId, ...paths: string[]) =>
59
56
  join(DATA_DIR(), id, ...paths)
60
-
61
- if (DEBUG()) {
62
- logSettings(settings)
57
+ export const INTERNAL_HOST = () => settings.PH_INTERNAL_HOST
58
+ export const INTERNAL_APP_SECRET = () => settings.PH_INTERNAL_APP_SECRET
59
+ export const INTERNAL_APP_URL = (...paths: string[]) =>
60
+ `${HTTP_PROTOCOL()}//${INTERNAL_HOST()}:${PORT()}/${INTERNAL_APP_PREFIX}/${join(
61
+ ...paths,
62
+ )}`
63
+ export const INTERNAL_APP_PREFIX = `_internal`
64
+ export const INTERNAL_APP_AUTH_HEADER = `x-pockethost-authorization`
65
+ export const HTTP_PROTOCOL = () => (PORT() === 443 ? 'https:' : 'http:')
66
+ export const PUBLIC_INSTANCE_URL = ({ subdomain }: Partial<InstanceFields>) => {
67
+ const url = new URL(`${HTTP_PROTOCOL()}//${subdomain}.${APEX_DOMAIN()}`)
68
+ url.port = `${PORT() === 80 || PORT() == 443 ? '' : PORT()}`
69
+ return url
63
70
  }
@@ -0,0 +1,108 @@
1
+ import { forEach, keys, omit, uniq } from '@s-libs/micro-dash'
2
+ import { parse } from 'dotenv'
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
4
+ import { info } from '../cli'
5
+ import { PH_HOME, settings } from '../constants'
6
+
7
+ const envFile = () => {
8
+ const envFile = PH_HOME(`.env`)
9
+ if (!existsSync(envFile)) {
10
+ writeFileSync(envFile, '')
11
+ }
12
+ return envFile
13
+ }
14
+
15
+ export const setConfig = (name: string, value: string) => {
16
+ if (value === '=') throw new Error(`Invalid value ${value}`)
17
+
18
+ const values = _parse()
19
+ values[name] = value
20
+ writeFileSync(
21
+ envFile(),
22
+ Object.entries(values)
23
+ .map(([k, v]) => `${k}=${v}`)
24
+ .join('\n'),
25
+ )
26
+ process.env[name] = value
27
+ info(`Set ${name}=${value}`)
28
+ info(`Written to ${envFile()}`)
29
+ }
30
+
31
+ export const unsetConfig = (name: string) => {
32
+ const values = _parse()
33
+ delete values[name]
34
+ writeFileSync(
35
+ envFile(),
36
+ Object.entries(values)
37
+ .map(([k, v]) => `${k}=${v}`)
38
+ .join('\n'),
39
+ )
40
+ info(`Unset ${name}`)
41
+ info(`Written to ${envFile()}`)
42
+ }
43
+
44
+ export const listConfig = () => {
45
+ const values = _parse()
46
+
47
+ if (keys(values).length > 0) {
48
+ info()
49
+ info(`Config values from ${envFile()}:`)
50
+ forEach(values, (v, k) => {
51
+ info(`\t${k}=${v}`)
52
+ })
53
+ info()
54
+ } else {
55
+ info(`No config values found in ${envFile()}`)
56
+ }
57
+
58
+ const defaults = omit(settings, keys(values) as any)
59
+ if (keys(defaults).length > 0) {
60
+ info(`Default values:`)
61
+ forEach(settings, (v, k) => {
62
+ if (k in values) return
63
+ info(`\t${k}=${v}`)
64
+ })
65
+ } else {
66
+ info(`No default values because all values are defined in ${envFile()}`)
67
+ }
68
+ }
69
+
70
+ export const appendConfig = (name: string, value: string) => {
71
+ const values = _parse()
72
+ values[name] = uniq([
73
+ ...(values[name]
74
+ ?.split(/,/)
75
+ .map((v) => v.trim())
76
+ .filter((v) => !!v) || []),
77
+ value,
78
+ ]).join(`,`)
79
+ writeFileSync(
80
+ envFile(),
81
+ Object.entries(values)
82
+ .map(([k, v]) => `${k}=${v}`)
83
+ .join('\n'),
84
+ )
85
+ info(`Added ${value} to ${name}`)
86
+ info(`Written to ${envFile()}`)
87
+ }
88
+
89
+ export const filterConfig = (name: string, value: string) => {
90
+ const values = _parse()
91
+ values[name] = uniq([
92
+ ...(values[name]
93
+ ?.split(/,/)
94
+ .map((v) => v.trim())
95
+ .filter((v) => v !== value) || []),
96
+ ]).join(`,`)
97
+ writeFileSync(
98
+ envFile(),
99
+ Object.entries(values)
100
+ .map(([k, v]) => `${k}=${v}`)
101
+ .join('\n'),
102
+ )
103
+ info(`Filtered ${value} from ${name}`)
104
+ info(`Written to ${envFile()}`)
105
+ }
106
+
107
+ export const _parse = () =>
108
+ parse(readFileSync(envFile(), { encoding: 'utf8' }).toString())
package/src/core/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export * from '../constants'
2
2
  export * from './Settings'
3
3
  export * from './asyncExecutionGuard'
4
+ export * from './config'
4
5
  export * from './exit'
5
6
  export * from './internal'
6
- export * from './logSettings'
7
7
  export * from './smartFetch'
8
8
  export * from './tryFetch'
@@ -1,4 +1,4 @@
1
- import fetch, { Response } from 'node-fetch'
1
+ import fetch, { RequestInit, Response } from 'node-fetch'
2
2
  import { LoggerService } from '../common'
3
3
 
4
4
  export const TRYFETCH_RETRY_MS = 50
@@ -6,7 +6,7 @@ export const TRYFETCH_RETRY_MS = 50
6
6
  export type TryFetchConfig = {
7
7
  preflight: () => Promise<boolean>
8
8
  retryMs: number
9
- }
9
+ } & RequestInit
10
10
 
11
11
  /**
12
12
  * @param url The URL to fetch
@@ -53,7 +53,10 @@ export const tryFetch = async (
53
53
  }
54
54
  try {
55
55
  dbg(`Fetch: START`)
56
- const res = await fetch(url, { signal: AbortSignal.timeout(500) })
56
+ const res = await fetch(url, {
57
+ ...config,
58
+ signal: AbortSignal.timeout(500),
59
+ })
57
60
  dbg(`Fetch: SUCCESS`)
58
61
  resolve(res)
59
62
  } catch (e) {
package/src/plugin.ts CHANGED
@@ -1,14 +1,19 @@
1
+ import { forEach } from '@s-libs/micro-dash'
1
2
  import { produce } from 'immer'
2
3
  import {
3
4
  PocketHostPlugin,
5
+ doSettingsFilter,
4
6
  namespace,
7
+ newId,
8
+ onAfterPluginsLoadedAction,
5
9
  onInstanceConfigFilter,
6
10
  onInstanceLogAction,
7
11
  onServeAction,
8
12
  onServeSlugsFilter,
9
13
  } from '../common'
10
- import { dbg } from './cli'
11
- import { PH_PROJECT_DIR } from './constants'
14
+ import { dbg, info } from './cli'
15
+ import { INTERNAL_APP_SECRET, PH_PROJECT_DIR, settings } from './constants'
16
+ import { setConfig } from './core/config'
12
17
  import { serve } from './server'
13
18
 
14
19
  export const pockethost: PocketHostPlugin = async ({}) => {
@@ -33,4 +38,17 @@ export const pockethost: PocketHostPlugin = async ({}) => {
33
38
  onServeSlugsFilter(async (slugs) => {
34
39
  return [...slugs, `pockethost`]
35
40
  })
41
+
42
+ onAfterPluginsLoadedAction(async () => {
43
+ const allSettings = await doSettingsFilter(settings)
44
+ forEach(allSettings, (v, k) => {
45
+ dbg(`${k}: ${v}`)
46
+ })
47
+ }, 99)
48
+
49
+ onAfterPluginsLoadedAction(async () => {
50
+ if (INTERNAL_APP_SECRET()) return
51
+ info(`Generating internal app secret...`)
52
+ setConfig(`PH_INTERNAL_APP_SECRET`, newId(30))
53
+ })
36
54
  }
package/src/server.ts CHANGED
@@ -12,7 +12,14 @@ import {
12
12
  doRequestErrorAction,
13
13
  doRequestErrorMessageFilter,
14
14
  } from '../common'
15
- import { APEX_DOMAIN, PORT, asyncExitHook } from '../core'
15
+ import {
16
+ APEX_DOMAIN,
17
+ INTERNAL_APP_AUTH_HEADER,
18
+ INTERNAL_APP_PREFIX,
19
+ INTERNAL_APP_SECRET,
20
+ PORT,
21
+ asyncExitHook,
22
+ } from '../core'
16
23
 
17
24
  export const serve = async () => {
18
25
  const _proxyLogger = LoggerService().create('ProxyService')
@@ -45,7 +52,25 @@ export const serve = async () => {
45
52
  }
46
53
  app.use(errorHandler)
47
54
 
48
- doAppMountedAction({ app })
55
+ const internalApp = express()
56
+ internalApp.use((req, res, next) => {
57
+ dbg(`Got a request to internal app ${req.url}`)
58
+ next()
59
+ })
60
+ internalApp.use((req, res, next) => {
61
+ const authHeader = req.headers[INTERNAL_APP_AUTH_HEADER]
62
+ const internalAppSecret = INTERNAL_APP_SECRET()
63
+
64
+ if (authHeader !== internalAppSecret) {
65
+ res.status(403).send('Forbidden')
66
+ return
67
+ }
68
+
69
+ next()
70
+ })
71
+ app.use(`/${INTERNAL_APP_PREFIX}`, internalApp)
72
+
73
+ doAppMountedAction({ app, internalApp })
49
74
 
50
75
  app.all(`*`, async (req, res, next) => {
51
76
  const method = req.method
@@ -83,11 +108,7 @@ export const serve = async () => {
83
108
  })
84
109
 
85
110
  const url = await doGetOrProvisionInstanceUrlFilter('', {
86
- req,
87
- res,
88
111
  instance,
89
- host,
90
- subdomain,
91
112
  })
92
113
 
93
114
  dbg(`Forwarding request for ${req.url} to instance ${url}`)
@@ -1,7 +0,0 @@
1
- import { forEach } from '@s-libs/micro-dash'
2
-
3
- export const logSettings = (settings: { [_: string]: any }) => {
4
- forEach(settings, (v, k) => {
5
- console.log(`${k}: ${v}`)
6
- })
7
- }