Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(starter): starter improvements to avoid port clashes #505

Merged
merged 22 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
207d94a
Restrict app names
kevin-dp Sep 27, 2023
b0ed4b9
Set Docker compose project name to app name
kevin-dp Sep 27, 2023
ecd5dbd
Fetch PG host port based on app name prefix in container name and pri…
kevin-dp Sep 27, 2023
ecbe5bd
Let user chose a port (optional) and fix the port in the generated pr…
kevin-dp Sep 27, 2023
17d2201
Add ports:configure command to change the ports the project is using.
kevin-dp Sep 28, 2023
6e3e48b
Always validate app name
kevin-dp Sep 28, 2023
5e44cf6
Raise warning if Electric fails to start because port is taken
kevin-dp Sep 28, 2023
ef5d3af
Also raise error if port is taken when starting only Electric.
kevin-dp Sep 28, 2023
2461291
Make Electric port replacement in builder.js more robust
kevin-dp Sep 28, 2023
ac84395
Raise error when the app's Electric is not running in order to avoid …
kevin-dp Sep 28, 2023
5d2d45a
Remove obsolete comments. Fix undefined spinner
kevin-dp Sep 28, 2023
7efb27b
Fixed build script to send request to the port of the right esbuild s…
kevin-dp Sep 28, 2023
cb43e50
Generate changeset
kevin-dp Sep 28, 2023
020684d
Improved changeset description
kevin-dp Sep 28, 2023
4493c53
Clarify why port 5433 must be exposed in a comment
kevin-dp Oct 3, 2023
af81cdb
Fix electric:check call in package.json
kevin-dp Oct 3, 2023
17b0705
Turned error constant into a function such that it does not complain …
kevin-dp Oct 3, 2023
cf4cc52
Improve prompt for new port
kevin-dp Oct 3, 2023
0e18721
Improve robustness of code that replaces webserver port
kevin-dp Oct 3, 2023
d1f44dd
Print url to app
kevin-dp Oct 3, 2023
a8764b0
Use shelljs-live instead of shelljs
kevin-dp Oct 3, 2023
bd0a60f
Make the "backend:stop" script more like "docker compose stop"
alco Oct 3, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/twelve-poems-exercise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"create-electric-app": patch
---

Improved starter such that several (independent) Electric projects can run concurrently.
The starter now has 2 modes: fast mode and interactive mode.
In fast mode, you can provide the app name and optional ports for Electric and the webserver as arguments.
In interactive mode, the script will prompt for an app name and ports (suggesting defaults).
Port clashes are now detected and reported to the user.
The user can change the ports the app uses by invoking 'yarn ports:configure'.
Also fixes the bug where all Electric applications would forward requests to the esbuild server that is running on port 8000 instead of their own esbuild server.
9 changes: 6 additions & 3 deletions examples/starter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@
"devDependencies": {
"@tsmodule/tsmodule": "42",
"@types/node": "^18.8.4",
"typescript": "^5.1.3",
"@types/shelljs": "^0.8.12"
"@types/shelljs": "^0.8.12",
"@types/tcp-port-used": "^1.0.2",
"typescript": "^5.1.3"
},
"dependencies": {
"ora": "^7.0.1",
"shelljs": "^0.8.5"
"prompt": "^1.3.0",
"shelljs": "^0.8.5",
"tcp-port-used": "^1.0.2"
}
}
192 changes: 182 additions & 10 deletions examples/starter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,110 @@ import { fileURLToPath } from 'url'
import path from 'path'
import ora from 'ora'
import shell from 'shelljs'
import portUsed from 'tcp-port-used'
import prompt from 'prompt'

const spinner = ora('Creating project structure').start()
// Regex to check that a number is between 0 and 65535
const portRegex = /^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/
const spinner = ora('Validating arguments').start()

// The first argument will be the project name
const projectName = process.argv[2]
const error = (err: string) => {
spinner.stop()
console.error('\x1b[31m', err + '\nnpx create-electric-app [<app-name>] [--electric-port <port>] [--webserver-port <port>]')
process.exit(1)
}

let projectName = process.argv[2]
let args = process.argv.slice(3)
let electricPort = 5133 // default port for Electric
let webserverPort = 3001 // default port for the webserver

// Validate the provided command line arguments
while (args.length > 0) {
// There are arguments to parse
const flag = args[0]
const value = args[1]

args = args.slice(2)

const checkValue = () => {
if (typeof value === 'undefined') {
error(`Missing value for option '${flag}'.`)
}
}

switch (flag) {
case '--electric-port':
checkValue()
electricPort = parsePort(value)
break
case '--webserver-port':
checkValue()
webserverPort = parsePort(value)
break
default:
error(`Unrecognized option: '${flag}'.`)
}
}

spinner.text = 'Validating app name'
const appNameRegex = /^[a-z0-9]+[a-z0-9-_]*$/
const invalidAppNameMessage = `Invalid app name. ` +
'App names must contain only lowercase letters, decimal digits, dashes, and underscores, ' +
'and must begin with a lowercase letter or decimal digit.'

if (typeof projectName === 'undefined') {
// no project name is provided -> enter prompt mode
spinner.stop()
prompt.start()
const userInput = (await prompt.get({
properties: {
appName: {
description: 'App name',
type: 'string',
// Validate the project name to follow
// the restrictions for Docker compose project names.
// cf. https://docs.docker.com/compose/environment-variables/envvars/
// Because we will use the app name
// as the Docker compose project name.
pattern: appNameRegex,
message: invalidAppNameMessage,
required: true,
},
electricPort: {
description: 'Port on which to run Electric',
type: 'number',
pattern: portRegex,
message: 'Port should be between 0 and 65535.',
default: electricPort
},
webserverPort: {
description: 'Port on which to run the webserver',
type: 'number',
pattern: portRegex,
message: 'Port should be between 0 and 65535.',
default: webserverPort
},
}
})) as { appName: string, electricPort: number, webserverPort: number }

spinner.start()
projectName = userInput.appName
electricPort = userInput.electricPort
webserverPort = userInput.webserverPort
}

spinner.text = 'Ensuring the necessary ports are free'

if (!appNameRegex.test(projectName)) {
error(invalidAppNameMessage)
}

electricPort = await checkPort(electricPort, 'Electric', 5133)
webserverPort = await checkPort(webserverPort, 'the web server', 3001)

spinner.text = 'Creating project structure'
spinner.start()

// Create a project directory with the project name
const currentDir = process.cwd()
Expand Down Expand Up @@ -55,15 +154,26 @@ const projectPackageJson = JSON.parse(await fs.readFile(packageJsonFile, 'utf8')
projectPackageJson.name = projectName

await fs.writeFile(
path.join(projectDir, 'package.json'),
JSON.stringify(projectPackageJson, null, 2)
packageJsonFile,
JSON.stringify(projectPackageJson, null, 2).replace('http://localhost:5133', `http://localhost:${electricPort}`)
)

// Update the project's title in the index.html file
const indexFile = path.join(projectDir, 'public', 'index.html')
const index = await fs.readFile(indexFile, 'utf8')
const newIndex = index.replace('ElectricSQL starter template', projectName)
await fs.writeFile(indexFile, newIndex)
await findAndReplaceInFile('ElectricSQL starter template', projectName, indexFile)

// Update the port on which Electric runs in the builder.js file
const builderFile = path.join(projectDir, 'builder.js')
await findAndReplaceInFile('ws://localhost:5133', `ws://localhost:${electricPort}`, builderFile)

// Update the port on which Electric runs in startElectric.js file
const startElectricFile = path.join(projectDir, 'backend', 'startElectric.js')
await findAndReplaceInFile('5133', `${electricPort}`, startElectricFile)

// Update the port of the web server of the example in the builder.js file
await findAndReplaceInFile("listen(3001)", `listen(${webserverPort})`, builderFile)
await findAndReplaceInFile("http://localhost:3001", `http://localhost:${webserverPort}`, builderFile)


// Store the app's name in .envrc
// db name must start with a letter
Expand All @@ -73,9 +183,12 @@ await fs.writeFile(indexFile, newIndex)
const name = projectName.match(/[a-zA-Z].*/)?.[0] // strips prefix of non-alphanumeric characters
if (name) {
const dbName = name.replace(/[\W_]+/g, '_')
await fs.appendFile(envrcFile, `export APP_NAME=${dbName}`)
await fs.appendFile(envrcFile, `export APP_NAME=${dbName}\n`)
}

// Also write the port for Electric to .envrc
await fs.appendFile(envrcFile, `export ELECTRIC_PORT=${electricPort}\n`)

// Run `yarn install` in the project directory to install the dependencies
// Also run `yarn upgrade` to replace `electric-sql: latest` by `electric-sql: x.y.z`
// where `x.y.z` corresponds to the latest version.
Expand All @@ -96,6 +209,7 @@ proc.on('close', async (code) => {
if (code === 0) {
// Pull latest electric image from docker hub
// such that we are sure that it is compatible with the latest client
spinner.text = 'Pulling latest Electric image'
shell.exec('docker pull electricsql/electric:latest', { silent: true })

const { stdout } = shell.exec("docker image inspect --format '{{.RepoDigests}}' electricsql/electric:latest", { silent: true })
Expand All @@ -111,7 +225,7 @@ proc.on('close', async (code) => {
}

// write the electric image to use to .envrc file
await fs.appendFile(envrcFile, `\nexport ELECTRIC_IMAGE=${electricImage}\n`)
await fs.appendFile(envrcFile, `export ELECTRIC_IMAGE=${electricImage}\n`)
}

spinner.stop()
Expand All @@ -125,3 +239,61 @@ proc.on('close', async (code) => {

console.log(`Navigate to your app folder \`cd ${projectName}\` and follow the instructions in the README.md.`)
})

/*
* Replaces the first occurence of `find` by `replace` in the file `file`.
* If `find` is a regular expression that sets the `g` flag, then it replaces all occurences.
*/
async function findAndReplaceInFile(find: string | RegExp, replace: string, file: string) {
const content = await fs.readFile(file, 'utf8')
const replacedContent = content.replace(find, replace)
await fs.writeFile(file, replacedContent)
}

/**
* Checks if the given port is open.
* If not, it will ask the user if
* they want to choose another port.
* @returns The chosen port.
*/
async function checkPort(port: number, process: string, defaultPort: number): Promise<number> {
const portOccupied = await portUsed.check(port)
if (!portOccupied) {
return port
}

spinner.stop()

// Warn the user that the chosen port is occupied
console.warn(`Port ${port} for ${process} is already in use.`)
// Propose user to change port
prompt.start()

const { port: newPort } = (await prompt.get({
properties: {
port: {
description: 'Hit Enter to keep it or enter a different port number',
type: 'number',
pattern: portRegex,
message: 'Please choose a port between 0 and 65535',
default: port,
}
}
}))

if (newPort === port) {
// user chose not to change port
return newPort
}
else {
// user changed port, check that it is free
return checkPort(newPort, process, defaultPort)
}
}

function parsePort(port: string): number {
if (!portRegex.test(port)) {
error(`Invalid port '${port}. Port should be between 0 and 65535.'`)
}
return Number.parseInt(port)
}
1 change: 1 addition & 0 deletions examples/starter/src/prompt.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'prompt'
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
version: "3.8"
name: "${APP_NAME:-electric}"

configs:
postgres_config:
Expand Down Expand Up @@ -38,7 +39,7 @@ services:
LOGICAL_PUBLISHER_HOST: electric
AUTH_MODE: insecure
ports:
- 5133:5133
- ${ELECTRIC_PORT:-5133}:5133
volumes:
- electric_data:/app/data
depends_on:
Expand Down
18 changes: 18 additions & 0 deletions examples/starter/template/backend/startCompose.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const exec = require('shelljs-live')
const path = require('path')

const envrcFile = path.join(__dirname, 'compose', '.envrc')
const composeFile = path.join(__dirname, 'compose', 'docker-compose.yaml')

const cliArguments = process.argv.slice(2).join(' ')

const res = exec(`docker compose --env-file ${envrcFile} -f ${composeFile} up ${cliArguments}`)

if (res.code !== 0 && res.stderr.includes('port is already allocated')) {
// inform the user that they should change ports
console.error(
'\x1b[31m',
'Could not start Electric because the port seems to be taken.\n' +
'To run Electric on another port execute `yarn ports:configure`'
)
}
63 changes: 51 additions & 12 deletions examples/starter/template/backend/startElectric.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,74 @@
const shell = require('shelljs')
const exec = require('shelljs-live')

let db = process.env.DATABASE_URL
let electricPort = process.env.ELECTRIC_PORT ?? 5133

if (process.argv.length === 4) {
const command = process.argv[2]
let args = process.argv.slice(2)

if (command !== '-db') {
console.error(`Unsupported option ${command}. Only '-db' option is supported.`)
while (args.length > 0) {
// There are arguments to parse
const flag = args[0]
const value = args[1]

process.exit(1)
args = args.slice(2)

const checkValue = () => {
if (typeof value === 'undefined') {
error(`Missing value for option '${flag}'.`)
}
}

db = process.argv[3]
switch (flag) {
case '-db':
checkValue()
db = value
break
case '--electric-port':
checkValue()
parseElectricPort(value)
break
default:
error(`Unrecognized option: '${flag}'.`)
}
}
else if (process.argv.length !== 2) {
console.log('Wrong number of arguments provided. Only one optional argument `-db <Postgres connection url>` is supported.')

function parseElectricPort(port) {
// checks that the number is between 0 and 65535
const portRegex = /^([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/
if (!portRegex.test(port)) {
kevin-dp marked this conversation as resolved.
Show resolved Hide resolved
error(`Invalid port '${port}. Port should be between 0 and 65535.'`)
}
electricPort = port
}

if (db === undefined) {
console.error(`Database URL is not provided. Please provide one using the DATABASE_URL environment variable.`)

process.exit(1)
}

const electric = process.env.ELECTRIC_IMAGE ?? "electricsql/electric:latest"

shell.exec(
// 5433 is the logical publisher port
// it is exposed because PG must be able to connect to Electric
const res = exec(
`docker run \
-e "DATABASE_URL=${db}" \
-e "LOGICAL_PUBLISHER_HOST=localhost" \
-e "AUTH_MODE=insecure" \
-p 5133:5133 \
-p ${electricPort}:5133 \
-p 5433:5433 ${electric}`
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need to expose this port?
The compose file is not binding to 5433 so i'm assuming it is not necessary here either and we can remove it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5433 is the logical publisher port. I think it is being exposed here to support setup where people are running their own postgres on the host while Electric itself is run inside a Docker container.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do need to expose it, because we need the PG to be able to connect to the electric. In this case, isn’t the script for when you’re running Electric without Postgres in the same compose?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes, i see. Perhaps i'll leave a comment to remember.
No the script for running only Electric is this separate file that executes docker run because there are some differences (PG url can be passed as an argument, and 5433 is not exposed in compose file).


if (res.code !== 0 && res.stderr.includes('port is already allocated')) {
// inform the user that they should change ports
console.error(
'\x1b[31m',
'Could not start Electric because the port seems to be taken.\n' +
'To run Electric on another port execute `yarn ports:configure`'
)
}

function error(err) {
console.error('\x1b[31m', err + '\nyarn electric:start [-db <Postgres connection url>] [-p <Electric port>]')
process.exit(1)
}
Loading
Loading