Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions examples/ingress/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
build
.env
.DS_Store
1 change: 1 addition & 0 deletions examples/ingress/.prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@gravity-ui/prettier-config');
7 changes: 7 additions & 0 deletions examples/ingress/.stylelintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": [
"@gravity-ui/stylelint-config",
"@gravity-ui/stylelint-config/order",
"@gravity-ui/stylelint-config/prettier"
]
}
3 changes: 3 additions & 0 deletions examples/ingress/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ingress

Bootstrapped with @gravity-ui/create.
49 changes: 49 additions & 0 deletions examples/ingress/app-builder.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as path from 'node:path';

import {newNginxIngressListeningHandler} from '@examples/ingress-nginx';
import type {ServiceConfig} from '@gravity-ui/app-builder';

import {
APP_ASSETS_MANIFEST_FILE,
APP_OUTPUT_PATH,
APP_PORT,
APP_PUBLIC_PATH,
APP_SOCKET,
} from './src/server/config/app-builder';

export default (): ServiceConfig => {
return {
client: {
assetsManifestFile: APP_ASSETS_MANIFEST_FILE,
devServer: {
port: APP_PORT ? APP_PORT + 1 : undefined,
// Fires once the client dev server is listening — the moment its socket/port
// (the /build/ upstream) becomes reachable for the reverse tunnel.
onListening: (devServer) => {
const nginxIngress = newNginxIngressListeningHandler({
template: path.resolve('templates', 'nginx.conf.template'),
serverName: process.env.INGRESS_SERVER_NAME ?? 'ingress.example',
remote: {
host: process.env.INGRESS_HOST ?? 'me@dev-vm',
// /build/ upstream → the client dev server (local end read from onListening).
client: {reversePort: 3031},
// everything else → the node server on its ipc socket.
server: {reversePort: 3030, socket: APP_SOCKET, localPort: APP_PORT},
},
});

nginxIngress.handle(devServer);

process.once('SIGINT', nginxIngress.stop);
process.once('SIGTERM', nginxIngress.stop);
},
},
newJsxTransform: true,
outputPath: APP_OUTPUT_PATH ? path.resolve(APP_OUTPUT_PATH) : undefined,
publicPath: APP_PUBLIC_PATH,
},
server: {
port: APP_PORT,
},
};
};
33 changes: 33 additions & 0 deletions examples/ingress/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import baseConfig from '@gravity-ui/eslint-config/base';
import clientConfig from '@gravity-ui/eslint-config/client';
import importOrderConfig from '@gravity-ui/eslint-config/import-order';
import serverConfig from '@gravity-ui/eslint-config/server';
import typescriptConfig from '@gravity-ui/eslint-config/typescript';
import {defineConfig} from 'eslint/config';
import globals from 'globals';

export default defineConfig(
baseConfig,
importOrderConfig,
{
files: ['.prettierrc.js', 'app-builder.config.ts'],
languageOptions: {
globals: {
...globals.node,
},
},
},
typescriptConfig,
{
files: ['./src/server/**/*'],
extends: [serverConfig],
},
{
files: ['./src/ui/**/*'],
extends: [clientConfig],
rules: {
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
},
},
);
5 changes: 5 additions & 0 deletions examples/ingress/lib/nginx/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
build
.env
.DS_Store
1 change: 1 addition & 0 deletions examples/ingress/lib/nginx/.prettierrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@gravity-ui/prettier-config');
60 changes: 60 additions & 0 deletions examples/ingress/lib/nginx/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# @examples/ingress-nginx

A tiny app-builder library that fronts a local `app-builder dev` server with a real nginx on a
remote VM, reached over `ssh -R` reverse tunnels. Generic and topology-agnostic: it **renders** an
nginx config from an explicit param map and **syncs** it over ssh. The actual nginx layout
(server_name, upstreams, SSL) lives with the consumer — see `examples/ingress/templates/nginx.conf.template`.

## Modules

- `src/nginx.ts` — `render(template, params)` (pure `${NAME}` substitution, whitelist = keys of
`params`; nginx's own `$vars` are left intact) + `DEFAULT_RELOAD_COMMAND`.
- `src/sync.ts` — `sync(config, target)` — pushes the config over ssh (`sudo tee` + reload) and
opens the reverse forwards (one `ssh` process, one `-R` per tunnel); returns `{stop}`.
- `src/index.ts` — `newNginxIngressListeningHandler(options)` returns `{handle, stop}`: `handle` is
the `onListening` callback (reads the client dev server's live address; node endpoint from
`options`), `stop` tears the tunnels down.

## Usage

Build the handler once from options, assign `handle` to `onListening`, and wire `stop` to signals:

```ts
// app-builder.config.ts
import {newNginxIngressListeningHandler} from '@examples/ingress-nginx';

const ingress = newNginxIngressListeningHandler({
template: 'templates/nginx.conf.template', // your layout, resolved from cwd
serverName: 'ingress.me.ui.yandex.cloud',
remote: {
host: 'me.ui.cloud.yandex.net', // ssh target that runs nginx
client: {reversePort: 3031}, // VM port (ssh -R bind) → client dev server (/build/)
server: {reversePort: 3030, socket: 'dist/run/server.sock'}, // VM port → node server
},
});

process.once('SIGINT', ingress.stop);
process.once('SIGTERM', ingress.stop);

export default {
client: {devServer: {onListening: ingress.handle}},
server: {
/* ... */
},
};
```

The template gets `APP_SERVER_NAME`, `APP_CLIENT_UPSTREAM` (`127.0.0.1:<client.reversePort>`) and
`APP_SERVER_UPSTREAM` (`127.0.0.1:<server.reversePort>`); pass anything else via `options.params`.

## Requirements on the VM

- nginx with the `common/ssl` / `common/gzip` includes and a cert for `serverName`.
- The remote user can write `configPath` (default `/etc/nginx/sites-enabled/<serverName>.conf`) and
run `reloadCommand` (default `sudo nginx -s reload`) — e.g. passwordless sudo.
- ssh access. Auth is interactive when the tunnel starts (use an agent / `ControlMaster` to avoid
repeat prompts).

## Build

`npm run build` (`app-builder build`) → `build/esm` + `build/cjs` + `.d.ts`.
7 changes: 7 additions & 0 deletions examples/ingress/lib/nginx/app-builder.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type {LibraryConfig} from '@gravity-ui/app-builder';

export default (): LibraryConfig => {
return {
lib: {},
};
};
20 changes: 20 additions & 0 deletions examples/ingress/lib/nginx/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import baseConfig from '@gravity-ui/eslint-config/base';
import importOrderConfig from '@gravity-ui/eslint-config/import-order';
import typescriptConfig from '@gravity-ui/eslint-config/typescript';
import {defineConfig} from 'eslint/config';
import globals from 'globals';

export default defineConfig(
{ignores: ['build/**']},
baseConfig,
importOrderConfig,
{
files: ['.prettierrc.cjs', 'src/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
},
},
},
typescriptConfig,
);
29 changes: 29 additions & 0 deletions examples/ingress/lib/nginx/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@examples/ingress-nginx",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "build/cjs/index.js",
"module": "build/esm/index.js",
"types": "build/esm/index.d.ts",
"files": [
"build"
],
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint",
"prettier": "prettier --list-different .",
"build": "NODE_ENV=production app-builder build"
},
"dependencies": {},
"devDependencies": {
"@gravity-ui/app-builder": "workspace:*",
"@gravity-ui/eslint-config": "^4.0.0",
"@gravity-ui/prettier-config": "^1.0.0",
"@gravity-ui/tsconfig": "^1.0.0",
"eslint": "^9.0.0",
"globals": "^17.0.0",
"prettier": "^3.0.0",
"typescript": "^5.3.0"
}
}
141 changes: 141 additions & 0 deletions examples/ingress/lib/nginx/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* nginx dev ingress — entrypoint.
*
* `newNginxIngressListeningHandler(options)` returns `{handle, stop}`:
* - `handle(devServer)` is meant to be assigned to `client.devServer.onListening`. When the dev
* server socket/port goes live it renders the nginx config (`nginx` module) and ships it to the
* remote host, holding the reverse tunnels open (`sync` module).
* - `stop()` tears the tunnels down; wire it to your process signals.
*/

import {readFileSync} from 'node:fs';
import path from 'node:path';

import {DEFAULT_RELOAD_COMMAND, render} from './nginx.js';
import {sync} from './sync.js';
import type {SyncHandle} from './sync.js';

/** Minimal structural shape of the webpack/rspack dev-server instance passed to `onListening`. */
export interface DevServerLike {
server?: {
address(): string | {port: number; address?: string} | null;
} | null;
}

export interface IngressOptions {
/** Path to the nginx template, resolved from the project cwd. */
template: string;
/** nginx server_name(s); the first also names the default remote config file. */
serverName: string | string[];
remote: {
/** ssh target that runs nginx, e.g. `me@dev-vm`. */
host: string;
/**
* `/build/` upstream. The remote nginx proxies to `127.0.0.1:<reversePort>`, which
* `ssh -R` forwards to the client dev server (its local address is read from `onListening`).
*/
client: {reversePort: number};
/**
* Everything-else upstream (the node server). nginx proxies to `127.0.0.1:<reversePort>`,
* forwarded to the node's local endpoint — a `socket` path (resolved from cwd) or a
* `localPort` — since `onListening` does not expose the node.
*/
server: {reversePort: number; socket?: string; localPort?: number};
/** Absolute path on the VM to write the config to. Default: `sites-enabled/<serverName>.conf`. */
configPath?: string;
/** Command to reload nginx over ssh. Default: `sudo nginx -s reload`. */
reloadCommand?: string;
/** Extra arguments for the ssh tunnel process. */
extraSshArgs?: string[];
};
/** Extra template params. Built-ins (`APP_SERVER_NAME`, `APP_*_UPSTREAM`) always win. */
params?: Record<string, string>;
}

export interface NginxIngressListeningHandler {
/** Assign to `client.devServer.onListening`. Runs the ingress setup once, on first listen. */
handle: (server: DevServerLike) => void;
/** Tear the reverse tunnels down. Wire to SIGINT/SIGTERM. */
stop: () => Promise<void>;
}

/** ipc socket path (string) or `localhost:<port>` from a dev server's `address()`. */

Check warning on line 62 in examples/ingress/lib/nginx/src/index.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Missing JSDoc for parameter 'address'

Check warning on line 62 in examples/ingress/lib/nginx/src/index.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Missing JSDoc @returns for function
function addressToLocalTarget(address: string | {port: number} | null | undefined): string {
if (!address) {
throw new Error('nginx ingress: dev server is not listening yet (no address)');
}
return typeof address === 'string' ? address : `localhost:${address.port}`;
}

async function setupIngress(server: DevServerLike, options: IngressOptions): Promise<SyncHandle> {
const {remote} = options;
const serverNames = Array.isArray(options.serverName)
? options.serverName
: [options.serverName];

// Client dev server: local address is discovered dynamically from the listening server.
const clientLocal = addressToLocalTarget(server.server?.address());
// Node server: not exposed by onListening, so its local endpoint comes from the config.
const serverLocal = remote.server.socket
? path.resolve(process.cwd(), remote.server.socket)
: `localhost:${remote.server.localPort}`;

const template = readFileSync(path.resolve(process.cwd(), options.template), 'utf8');
const conf = render(template, {
...options.params,
APP_SERVER_NAME: serverNames.join(' '),
APP_CLIENT_UPSTREAM: `127.0.0.1:${remote.client.reversePort}`,
APP_SERVER_UPSTREAM: `127.0.0.1:${remote.server.reversePort}`,
});

return sync(conf, {
host: remote.host,
configPath: remote.configPath ?? `/etc/nginx/sites-enabled/${serverNames[0]}.conf`,
reloadCommand: remote.reloadCommand ?? DEFAULT_RELOAD_COMMAND,
tunnels: [
{port: remote.client.reversePort, localTarget: clientLocal},
{port: remote.server.reversePort, localTarget: serverLocal},
],
extraSshArgs: remote.extraSshArgs,
});
}

export function newNginxIngressListeningHandler(
options: IngressOptions,
): NginxIngressListeningHandler {
let syncHandle: SyncHandle | undefined;
let started = false;
let stopped = false;

const handle = (server: DevServerLike) => {
// onListening can fire again after a dev-server restart; only set up once.
if (started) {
return;
}
started = true;

setupIngress(server, options)
.then((h) => {
// If stop() was already called while we were setting up, tear down immediately.
if (stopped) {
h.stop().catch(() => {});
} else {
syncHandle = h;
}
})
.catch((error: unknown) => {
console.error('[nginx-ingress] setup failed:', error);

Check warning on line 127 in examples/ingress/lib/nginx/src/index.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected console statement
});
};

const stop = async () => {
stopped = true;
if (syncHandle) {
const h = syncHandle;
syncHandle = undefined;
await h.stop();
}
};

return {handle, stop};
}
16 changes: 16 additions & 0 deletions examples/ingress/lib/nginx/src/nginx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* nginx config templating — pure, no I/O.
*
* Substitutes `${NAME}` placeholders from an explicit params map, mimicking `envsubst` with a
* whitelist: only keys present in `params` are replaced; every other `${...}` and all unbraced
* nginx variables (`$host`, `$scheme`, ...) are left untouched.
*/

/** Command that tells a running nginx to pick up the new config. */
export const DEFAULT_RELOAD_COMMAND = 'sudo nginx -s reload';

export function render(template: string, params: Record<string, string>): string {
return template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) =>
name in params ? params[name] : match,
);
}
Loading
Loading