Skip to content

Commit

Permalink
feat(examples): starter template (#314)
Browse files Browse the repository at this point in the history
This PR introduces a package containing a NodeJS script to bootstrap
Electric apps.

---------

Co-authored-by: James Arthur <[email protected]>
  • Loading branch information
kevin-dp and thruflo authored Aug 9, 2023
1 parent 0ab0429 commit 29c7cc3
Show file tree
Hide file tree
Showing 33 changed files with 1,874 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-humans-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"create-electric-app": patch
---

Starter template for bootstrapping Electric applications.
3 changes: 3 additions & 0 deletions examples/starter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
template/node_modules
dist
17 changes: 17 additions & 0 deletions examples/starter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

# ElectricSQL Starter App

This is a starter app template. You can use it to generate an ElectricSQL application. The app is setup to match the example code you can see in the [Quickstart](https://electric-sql.com/docs/quickstart).

## Pre-reqs

- Docker (with Compose V2)
- Node >= 16.11.0

## Usage

```sh
npx create-electric-app my-app
```

Change directory into the created folder (`./my-app` in the example command above) and then follow the instructions in the generated README.
22 changes: 22 additions & 0 deletions examples/starter/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "create-electric-app",
"version": "0.1.0",
"author": "ElectricSQL",
"license": "Apache-2.0",
"main": "dist/index.js",
"type": "module",
"platform": "node",
"files": [
"dist"
],
"bin": {
"create-electric-app": "dist/index.js"
},
"scripts": {
"build": "rm -rf ./dist && tsc && tsmodule build"
},
"devDependencies": {
"@types/node": "^18.8.4",
"@tsmodule/tsmodule": "42"
}
}
81 changes: 81 additions & 0 deletions examples/starter/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env node

// Usage: npx create-electric-app my-app

import { spawn } from 'child_process'
import * as fs from 'fs/promises'
import { fileURLToPath } from 'url'
import path from 'path'

// The first argument will be the project name
const projectName = process.argv[2]

// Create a project directory with the project name
const currentDir = process.cwd()
const projectDir = path.resolve(currentDir, projectName)
await fs.mkdir(projectDir, { recursive: true })

// Copy the app template to the project's directory
const __dirname = path.dirname(fileURLToPath(import.meta.url)) // because __dirname is not defined when using modules
const templateDir = path.resolve(__dirname, '..', 'template')
await fs.cp(templateDir, projectDir, { recursive: true })

// The template stores dotfiles without the dot
// such that they do not get picked by gitignore.
// Now that we copies all files, we rename those
// dotfiles to their right name
await fs.rename(
path.join(projectDir, 'dot_gitignore'),
path.join(projectDir, '.gitignore')
)
await fs.rename(
path.join(projectDir, 'dot_npmrc'),
path.join(projectDir, '.npmrc')
)
const envrcFile = path.join(projectDir, 'backend', 'compose', '.envrc')
await fs.rename(
path.join(projectDir, 'backend', 'compose', 'dot_envrc'),
envrcFile
)

// import package.json and deep copy it
// otherwise we can't edit it because
// the JSON object is not extensible
const projectPackageJson = (await import(path.join(projectDir, 'package.json'), { assert: { type: "json" } })).default

// Update the project's package.json with the new project name
projectPackageJson.name = projectName

await fs.writeFile(
path.join(projectDir, 'package.json'),
JSON.stringify(projectPackageJson, null, 2)
)

// 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)

// Store the app's name in .envrc
// db name must start with a letter
// and contain only alphanumeric characters and underscores
// so we let the name start at the first letter
// and replace non-alphanumeric characters with _
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}`)
}

// Run `yarn install` in the project directory to install the dependencies
const proc = spawn('yarn install', [], { stdio: 'inherit', cwd: projectDir, shell: true })

proc.on('close', (code) => {
if (code === 0) {
console.log(`Success! Your ElectricSQL app is ready at \`./${projectName}\``)
}
else {
console.log(`Could not install project dependencies. Nevertheless the template for your app can be found at \`./${projectName}\``)
}
})
47 changes: 47 additions & 0 deletions examples/starter/template/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@

# Welcome to your ElectricSQL app!

Start the backend:

```shell
yarn backend:start
```

Open a new tab in your terminal. Navigate back to the same folder. Apply the migrations (defined in `./db/migrations`):

```shell
yarn db:migrate
```

Generate your client:

```sh
yarn client:generate
```

Start your app:

```sh
yarn start
```

Open [localhost:3001](http://localhost:3001) in your web browser.

## Changing your database schema

You can watch for database schema changes and automatically generate a new client using:

```sh
yarn client:watch
```

## Notes

- `yarn backend:start` uses Docker Compose to start Postgres and the [Electric sync service](https://electric-sql.com/docs/api/service). See [running the examples](https://electric-sql.com/docs/examples/notes/running#running-your-own-postgres) for information about configuring the Electric sync service to run against an existing Postgres database.
- `yarn client:watch` calls `npx electric-sql generate --watch` under the hood. See [https://electric-sql.com/docs/api/generator](https://electric-sql.com/docs/api/generator) for more details.

## More information

- [Documentation](https://electric-sql.com/docs)
- [Quickstart](https://electric-sql.com/docs/quickstart)
- [Usage guide](https://electric-sql.com/docs/usage)
52 changes: 52 additions & 0 deletions examples/starter/template/backend/compose/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
version: "3.8"

configs:
postgres_config:
file: "./postgres/postgres.conf"

volumes:
pg_data:
electric_data:

services:
postgres:
image: "${POSTGRESQL_IMAGE:-postgres:14-alpine}"
environment:
POSTGRES_DB: ${APP_NAME:-electric}
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
command:
- -c
- config_file=/etc/postgresql.conf
configs:
- source: postgres_config
target: /etc/postgresql.conf
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- 5432
volumes:
- pg_data:/var/lib/postgresql/data

electric:
image: "${ELECTRIC_IMAGE:-electricsql/electric:latest}"
init: true
environment:
DATABASE_URL: postgresql://postgres:password@postgres:5432/${APP_NAME:-electric}
LOGICAL_PUBLISHER_HOST: electric
# Currently published version ([email protected])
# still uses `ELECTRIC_HOST` variable
# but future versions will use `LOGICAL_PUBLISHER_HOST`
# TODO: Remove `ELECTRIC_HOST` when next version is published.
ELECTRIC_HOST: electric
OFFSET_STORAGE_FILE: "/app/data/offset_storage.dat"
AUTH_MODE: insecure
ports:
- 5050:5050
- 5133:5133
volumes:
- electric_data:/app/data
depends_on:
- postgres
3 changes: 3 additions & 0 deletions examples/starter/template/backend/compose/dot_envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Docker images for the local stack
export POSTGRESQL_IMAGE=postgres:14-alpine
export ELECTRIC_IMAGE=electricsql/electric:latest
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
listen_addresses = '*'
wal_level = 'logical'
# log_min_messages = 'debug1'
# log_min_error_statement = 'debug1'
37 changes: 37 additions & 0 deletions examples/starter/template/backend/startElectric.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const shell = require('shelljs')

let db = process.env.DATABASE_URL

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

if (command !== '-db') {
console.error(`Unsupported option ${command}. Only '-db' option is supported.`)

process.exit(1)
}

db = process.argv[3]
}
else if (process.argv.length !== 2) {
console.log('Wrong number of arguments provided. Only one optional argument `-db <Postgres connection url>` is supported.')
}

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(
`docker run \
-e "DATABASE_URL=${db}" \
-e "ELECTRIC_HOST=localhost" \
-e "LOGICAL_PUBLISHER_HOST=localhost" \
-e "AUTH_MODE=insecure" \
-p 5050:5050 \
-p 5133:5133 \
-p 5433:5433 ${electric}`
)
93 changes: 93 additions & 0 deletions examples/starter/template/builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const { build, serve } = require('esbuild')

const { createServer, request } = require('http')
const { spawn } = require('child_process')

const fs = require('fs-extra')
const inlineImage = require('esbuild-plugin-inline-image')

const shouldMinify = process.env.NODE_ENV === 'production'
const shouldServe = process.env.SERVE === 'true'

// https://github.com/evanw/esbuild/issues/802#issuecomment-819578182
const liveServer = (buildOpts) => {
const clients = []

build(
{
...buildOpts,
banner: { js: ' (() => new EventSource("/esbuild").onmessage = () => location.reload())();' },
watch: {
onRebuild(error, result) {
clients.forEach((res) => res.write('data: update\n\n'))
clients.length = 0
console.log(error ? error : '...')
},
}
}
).catch(() => process.exit(1))

serve({servedir: 'dist' }, {})
.then(() => {
createServer((req, res) => {
const { url, method, headers } = req

if (url === '/esbuild')
return clients.push(
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
)

const path = ~url.split('/').pop().indexOf('.') ? url : `/index.html` //for PWA with router
req.pipe(
request({ hostname: '0.0.0.0', port: 8000, path, method, headers }, (prxRes) => {
res.writeHead(prxRes.statusCode, prxRes.headers)
prxRes.pipe(res, { end: true })
}),
{ end: true }
)
}).listen(3001)

setTimeout(() => {
const op = { darwin: ['open'], linux: ['xdg-open'], win32: ['cmd', '/c', 'start'] }
const ptf = process.platform
if (clients.length === 0) spawn(op[ptf][0], [...[op[ptf].slice(1)], `http://localhost:3001`])
}, 500) // open the default browser only if it is not opened yet
})
}

/**
* ESBuild Params
* @link https://esbuild.github.io/api/#build-api
*/
let buildParams = {
color: true,
entryPoints: ["src/index.tsx"],
loader: { ".ts": "tsx" },
outdir: "dist",
minify: shouldMinify,
format: "cjs",
bundle: true,
sourcemap: true,
logLevel: "error",
incremental: true,
external: ["fs", "path"],
plugins: [inlineImage()],
};

(async () => {
fs.removeSync("dist");
fs.copySync("public", "dist");

if (shouldServe) {
liveServer(buildParams)
}
else {
await build(buildParams)

process.exit(0)
}
})();
22 changes: 22 additions & 0 deletions examples/starter/template/copy-wasm-files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const { copyFile } = require('node:fs/promises')
const path = require('node:path')

// Copies the wasm files needed for wa-sqlite
// from `/node_modules/wa-sqlite/dist` into `public`
const waSqlitePath = path.join('node_modules', 'wa-sqlite', 'dist')
const publicFolder = 'public'

const mjsFileName = 'wa-sqlite-async.mjs'
const mjsFile = path.join(waSqlitePath, mjsFileName)
const mjsDest = path.join(publicFolder, mjsFileName)

const wasmFileName = 'wa-sqlite-async.wasm'
const wasmFile = path.join(waSqlitePath, wasmFileName)
const wasmDest = path.join(publicFolder, wasmFileName)

try {
copyFile(mjsFile, mjsDest)
copyFile(wasmFile, wasmDest)
} catch {
console.error('Could not copy wasm files required for wa-sqlite. Did you forget to run `npm install` ?')
}
Loading

0 comments on commit 29c7cc3

Please sign in to comment.