Skip to content

Commit 6075c23

Browse files
committed
🐣 portz: init
1 parent c3bb1b0 commit 6075c23

File tree

15 files changed

+731
-0
lines changed

15 files changed

+731
-0
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"packages/pifs",
3737
"packages/pkgu",
3838
"packages/portu",
39+
"packages/portz",
3940
"packages/r11y",
4041
"packages/ramdsk",
4142
"packages/rebox/*",
@@ -86,6 +87,8 @@
8687
"ws": "^7.3.1"
8788
},
8889
"scripts": {
90+
"service": "echo $PORT",
91+
"app": "echo $PORT $PORT_SERVICE",
8992
"start": "packages/nextools/start-preset/src/cli/index.js"
9093
},
9194
"start": {

packages/portz/license.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# The MIT License (MIT)
2+
3+
Copyright (c) 2021–present NexTools
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

packages/portz/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "portz",
3+
"version": "0.0.0",
4+
"description": "Service port registry and dependency queue manager",
5+
"keywords": [],
6+
"main": "src/index.ts",
7+
"bin": "src/cli.ts",
8+
"repository": "nextools/metarepo",
9+
"license": "MIT",
10+
"engines": {
11+
"node": ">=12.13.0"
12+
},
13+
"publishConfig": {
14+
"access": "public"
15+
},
16+
"dependencies": {
17+
"@babel/runtime": "^7.14.0",
18+
"commander": "^7.2.0",
19+
"dleet": "^2.0.1",
20+
"pifs": "^2.0.0",
21+
"portu": "^0.2.0",
22+
"sleap": "^0.1.0",
23+
"unchunk": "^0.2.0"
24+
},
25+
"devDependencies": {
26+
"@types/tape": "^4.2.34",
27+
"tape": "^5.0.0"
28+
}
29+
}

packages/portz/readme.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# portz ![npm](https://flat.badgen.net/npm/v/portz)
2+
3+
Service port registry and dependency queue manager.
4+
5+
Allows to run services concurrently respecting cross-dependencies by waiting for TCP port to become in use on `0.0.0.0` interface.
6+
7+
## Install
8+
9+
```sh
10+
$ yarn add portz
11+
```
12+
13+
## Usage
14+
15+
### API
16+
17+
```ts
18+
type TStartServerOptions = {
19+
fromPort: number,
20+
toPort: number
21+
}
22+
23+
const startServer: (options: TStartServerOptions) => Promise<() => Promise<void>>
24+
```
25+
26+
```ts
27+
type TRegisterServiceOptions = {
28+
name: string,
29+
deps?: string[]
30+
}
31+
32+
type TRegisterServiceResult = {
33+
port: number,
34+
deps?: {
35+
[k: string]: number
36+
},
37+
}
38+
39+
const registerService: (options: TRegisterServiceOptions) => Promise<TRegisterServiceResult>
40+
```
41+
42+
```ts
43+
const unregisterService: (name: string) => Promise<void>
44+
```
45+
46+
```ts
47+
import { startServer, registerService } from 'portz'
48+
49+
const stopServer = await startServer({
50+
fromPort: 3000,
51+
toPort: 4000
52+
})
53+
54+
console.log(
55+
await Promise.all([
56+
registerService({ name: 'foo', deps: ['bar'] }),
57+
registerService({ name: 'bar' }),
58+
registerService({ name: 'baz', deps: ['foo', 'bar'] }
59+
])
60+
)
61+
/*
62+
[
63+
{ port: 3001, deps: { bar: 3000 } },
64+
{ port: 3000 },
65+
{ port: 3002, deps: { foo: 3001, bar: 3000 } }
66+
]
67+
*/
68+
69+
await stopServer()
70+
```
71+
72+
### CLI
73+
74+
```json
75+
{
76+
"scripts": {
77+
"foo": "echo $PORT",
78+
"bar": "echo $PORT $PORT_FOO"
79+
}
80+
}
81+
```
82+
83+
```sh
84+
portz start --range 3000:4000
85+
```
86+
87+
```sh
88+
portz register --name foo -- npm run foo
89+
portz register --name bar --deps foo -- npm run bar
90+
```

packages/portz/src/cli.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env node
2+
import { spawn } from 'child_process'
3+
import { program } from 'commander'
4+
import { version } from '../package.json'
5+
import { registerService } from './register-service'
6+
import { startServer } from './start-server'
7+
import { unregisterService } from './unregister-service'
8+
9+
type TSignalMap = {
10+
[k in NodeJS.Signals]?: number
11+
}
12+
13+
const signals: TSignalMap = {
14+
SIGHUP: 1,
15+
SIGINT: 2,
16+
SIGTERM: 15,
17+
}
18+
19+
program.version(version)
20+
21+
program
22+
.command('register')
23+
.description('register service through portz server')
24+
.requiredOption('-n, --name [name]', 'service name')
25+
.option('-d, --deps <name...>', 'dependency name')
26+
.action(async ({ name, deps }) => {
27+
const result = await registerService({ name, deps })
28+
const dashDashArgIndex = program.args.findIndex((arg) => arg === '--')
29+
30+
if (dashDashArgIndex === -1) {
31+
throw new Error('Arguments to spawn a process are required')
32+
}
33+
34+
const spawnCommand = program.args[dashDashArgIndex + 1]
35+
const spawnArgs = program.args.slice(dashDashArgIndex + 2)
36+
37+
const depsEnv: { [k: string]: string } = {}
38+
39+
if (result.deps != null) {
40+
for (const [name, port] of Object.entries(result.deps)) {
41+
depsEnv[`PORT_${name.toUpperCase()}`] = String(port)
42+
}
43+
}
44+
45+
const childProcess = spawn(spawnCommand, spawnArgs, {
46+
stdio: 'inherit',
47+
env: {
48+
...process.env,
49+
...depsEnv,
50+
PORT: String(result.port),
51+
},
52+
})
53+
54+
childProcess.on('error', async (err) => {
55+
console.error(err)
56+
await unregisterService(name)
57+
process.exit(1)
58+
})
59+
60+
const kill = async (signal: NodeJS.Signals) => {
61+
await unregisterService(name)
62+
console.log('\nbye')
63+
// https://github.com/npm/cli/issues/1591
64+
// https://nodejs.org/api/process.html#process_exit_codes
65+
process.exit(128 + signals[signal]!)
66+
}
67+
68+
process.on('SIGTERM', kill)
69+
process.on('SIGINT', kill)
70+
process.on('SIGHUP', kill)
71+
})
72+
73+
program
74+
.command('unregister')
75+
.description('unregister service through portz server')
76+
.requiredOption('-n, --name [name]', 'service name')
77+
.action(({ name }) => unregisterService(name))
78+
79+
program
80+
.command('start')
81+
.description('start portz server')
82+
.requiredOption('-r, --range <from:to>', 'port range, separated by :', (value) => value.split(':'))
83+
.action(async ({ range }) => {
84+
const stopServer = await startServer({
85+
fromPort: range[0],
86+
toPort: range[1],
87+
})
88+
89+
const kill = async (signal: NodeJS.Signals) => {
90+
await stopServer()
91+
console.log('\nbye')
92+
// https://github.com/npm/cli/issues/1591
93+
// https://nodejs.org/api/process.html#process_exit_codes
94+
process.exit(128 + signals[signal]!)
95+
}
96+
97+
process.on('SIGTERM', kill)
98+
process.on('SIGINT', kill)
99+
process.on('SIGHUP', kill)
100+
101+
console.log('portz server is up and ready')
102+
})
103+
104+
program
105+
.parseAsync(process.argv)
106+
.catch((err) => {
107+
console.error(err)
108+
process.exit(1)
109+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { isPortFree } from 'portu'
2+
3+
export const getFreePort = async (from: number, to: number, skipPorts: number[]): Promise<number> => {
4+
const _from = Math.max(1, from)
5+
const _to = Math.min(65535, to)
6+
7+
for (let port = _from; port <= _to; port++) {
8+
if (skipPorts.includes(port)) {
9+
continue
10+
}
11+
12+
const isFree = await isPortFree(port, '0.0.0.0')
13+
14+
if (isFree) {
15+
return port
16+
}
17+
}
18+
19+
throw new Error(`Unable to find free port within ${from}-${to} range`)
20+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { tmpdir } from 'os'
2+
import path from 'path'
3+
import { realpath } from 'pifs'
4+
5+
let socketPath: null | string = null
6+
7+
export const getSocketPath = async () => {
8+
if (socketPath !== null) {
9+
return socketPath
10+
}
11+
12+
const tmpDir = await realpath(tmpdir())
13+
14+
socketPath = path.join(tmpDir, 'portz.sock')
15+
16+
return socketPath
17+
}

packages/portz/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './start-server'
2+
export * from './register-service'
3+
export * from './unregister-service'
4+
export type { TRegisterServiceOptions, TRegisterServiceResult } from './types'
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { once } from 'events'
2+
import http from 'http'
3+
import type { IncomingMessage } from 'http'
4+
import { unchunkJson } from 'unchunk'
5+
import { getSocketPath } from './get-socket-path'
6+
import type { TRegisterServiceOptions } from './types'
7+
8+
type TRegisterService = {
9+
<T extends string>(options: TRegisterServiceOptions & { deps: T[] }): Promise<{ port: number, deps: { [k in T]: number } }>,
10+
(options: TRegisterServiceOptions): Promise<{ port: number }>,
11+
}
12+
13+
export const registerService: TRegisterService = async (options: any) => {
14+
const socketPath = await getSocketPath()
15+
const data = JSON.stringify(options)
16+
17+
const req = http.request({
18+
method: 'POST',
19+
path: '/register',
20+
socketPath,
21+
headers: {
22+
'Content-Type': 'application/json',
23+
},
24+
})
25+
26+
req.write(data)
27+
req.end()
28+
29+
const [res] = await once(req, 'response') as [IncomingMessage]
30+
31+
if (res.statusCode !== 200) {
32+
throw new Error(res.statusMessage)
33+
}
34+
35+
const result = await unchunkJson<any>(res)
36+
37+
return result
38+
}

0 commit comments

Comments
 (0)