VSCode with S/4 Hana Classics (On Premise and Rise Private Edition) BTP Destinations
Share

[[{“value”:”

After many years of SAP projects, this is my very first blog post here 😃. I picked a topic that saves me time and nerves every day: running and deploying Fiori apps from my local VS Code against an on-premise S/4HANA system, through the same BTP destination and Cloud Connector that SAP Business Application Studio uses, without a dev space.

The whole trick fits in one command: cf ssh.

Why I stopped opening a dev space

Let me be clear first: SAP Business Application Studio is a great product. Generators, guided development, a ready-made toolchain in the browser. For many teams it is the right default.

But in daily life I kept hitting the same walls:

  • Disk space. Dev spaces have a limited quota. A few node_modules folders and an npm cache later, you are cleaning up instead of developing.
  • Dev spaces that don’t start, for no reason I could find. You wait, retry, and eventually create a new one.
  • npm run start that hangs, again for no visible reason. Same project, same command, works fine locally.

Then I asked myself an honest question: why do I open BAS at all? My laptop already has VS Code, Node.js, the Fiori tools and my extensions. The only reason is that my laptop cannot reach the customer’s S/4HANA directly. Many customers only give access through remote desktops or VDIs. The only path from the outside world into the backend is BTP → Cloud Connector.

So the only things I really need from BTP are the destination and the connectivity to the Cloud Connector. Nothing else.

blog-0-why.png

I also tried the SAP Business Application Studio desktop client for VS Code. It is a nice idea, but underneath it is still a dev space: same disk quota, same npm start that hangs, plus containers that sometimes hang on start.

So I went the other way: keep everything local and borrow only the connectivity from BTP, through cf ssh port forwarding.

The result in one table

Command What it does

npm run start-btp Starts the app on localhost:8081. Every /sap call goes through the BTP destination and the Cloud Connector.
npm run deploy-btp Builds the app and deploys it to the ABAP repository (BSP), through the same destination.
npm run deploy-btp-test Same as above in test mode. Nothing is written.

Both commands are one-shot: they start the tunnel and the local proxy when needed, or reuse the ones already running. You never start anything by hand. You don’t need a BAS session or VPN tricks, and no BTP credentials are stored on your disk.

I use it for Fiori / UI5 development, but the same tunnel works for CAP or anything else that speaks HTTP to the backend (see the end of the post).

How it works

The key observation: destinations and the Cloud Connector are only reachable from inside BTP. More precisely, the connectivity proxy, the component that forwards your HTTP calls into the Cloud Connector tunnel, is only reachable from the Cloud Foundry container network. Your laptop can’t reach it, but any CF app container can.

A CF app that is bound to a destination and a connectivity service instance has everything needed in its environment (VCAP_SERVICES). If SSH is enabled for that app, cf ssh -L can forward a local port, through the container, to the connectivity proxy.

A small local Node.js proxy (btp-proxy, about 180 lines, no dependencies) does the rest:

blog-1-architecture.png

  • The browser talks to fiori run on localhost:8081, as usual.
  • fiori-tools-proxy sends /sap to btp-proxy on localhost:5050 instead of to a destination.
  • btp-proxy adds what the connectivity proxy expects and sends the request into the cf ssh tunnel.
  • Inside CF, the request reaches the connectivity proxy, then the Cloud Connector picks it up and calls the backend.

The CF app is only used as a jump host. Its code is never called, so any small app in your space will do (more on that in the prerequisites).

Under the hood: what happens at start-up

blog-2-startup.png

  1. Read the bindings. cf app <app> --guid, then cf curl /v3/apps/<guid>/env returns the app’s VCAP_SERVICES, using the CF session you already have from cf login. The proxy keeps the destination and connectivity credentials in memory only. Nothing is written to disk, and no service key is created.
  2. Get two tokens. An OAuth client-credentials call to XSUAA for each binding: one JWT for the Destination service, one for the connectivity proxy. Both are cached and refreshed one minute before they expire.
  3. Resolve the destination. GET /destination-configuration/v1/destinations/<name> returns the virtual host URL, the sap-client and the CloudConnectorLocationId. The proxy checks that it is an OnPremise destination.
  4. Open the tunnel. cf ssh <app> -N -T -L 127.0.0.1:20003:<onpremise_proxy_host>:<onpremise_proxy_http_port>. Both values come from the connectivity binding. From now on, port 20003 on your laptop is the connectivity proxy.
  5. Listen on 127.0.0.1:5050 and print ready.

Under the hood: one request

blog-3-request.png

The connectivity proxy is a classic HTTP forward proxy. That explains the shape of the outgoing request:

  • The request line carries the absolute URI of the destination’s virtual host (GET http://s4h-virtual:443/sap/…), exactly like any browser behind a corporate proxy.
  • Proxy-Authorization: Bearer <JWT> tells the connectivity proxy which subaccount you are.
  • SAP-Connectivity-SCC-Location_ID selects the right Cloud Connector when several are connected to the subaccount.
  • sap-client is added from the destination when the request has none.
  • Your own Authorization header (your SAP user) is passed through unchanged. The backend still authenticates you, exactly as in BAS.

On the way back, two small rewrites keep the browser happy on http://localhost: redirects to the virtual host become relative again, and the Secure flag is removed from cookies.

The authentication gotcha I hit with fiori deploy

In our landscape, through the Cloud Connector, the backend only accepted a user/password logon after a first 401 round trip that sets the cookies sap-usercontext and sap-ssolist. Browsers (and BAS) do this naturally. fiori deploy doesn’t: it sends basic auth straight away and ends with an “authentication error”, without any failed logon in SU01. That one cost me some time.

The proxy handles it. When a request carries Authorization but no sap-usercontext cookie, it first sends one anonymous request, collects those two cookies and adds them to the real request. After that, deploy works.

Prerequisites

  • cf CLI v8, logged in and targeted at the space:
    cf login -a https://api.cf.<region>.hana.ondemand.com -o <org> -s <space> --sso
  • Node.js 18+ (the scripts use the built-in fetch).
  • The SpaceDeveloper role in that space. You need it to read app environments and to cf ssh.
  • A CF app bound to a destination AND a connectivity service instance, with SSH enabled. Check it with cf ssh-enabled <app>. If needed: cf allow-space-ssh <space> (space manager), cf enable-ssh <app>, then cf restart <app>.
  • The destination exists in the subaccount (Connectivity → Destinations), proxy type OnPremise, for example S4H_CLNT100.

Installing the cf CLI

Already installed? cf version prints cf version 8.x. Otherwise:

  Windows macOS

Package manager winget install CloudFoundry.CLI.v8
or choco install cloudfoundry-cli
brew install cloudfoundry/tap/cf-cli@8
Installer Download the installer from the official installation guide

No suitable app in your space? Push a tiny one

The app is only a jump host, so it can be as small as CF allows. Create the two service instances (entitlements needed), push an app that does nothing, and bind both:

cf create-service destination lite my-destination
cf create-service connectivity lite my-connectivity
# manifest.yml - push from an empty folder containing any small file
applications:
  - name: my-tunnel-app
    memory: 64M
    disk_quota: 256M
    instances: 1
    buildpacks: [binary_buildpack]
    command: sleep infinity
    no-route: true
    health-check-type: process
    services:
      - my-destination
      - my-connectivity

Then cf push and cf enable-ssh my-tunnel-app, followed by cf restart my-tunnel-app. The destinations themselves stay at subaccount level: any destination service instance in the subaccount can read them.

Add it to your project

1. Copy the two scripts

File Role

scripts/btp-proxy.js The proxy: reads the destination, opens the cf ssh tunnel, forwards requests
scripts/btp.js The one-shot start / deploy commands
.vscode/launch.json Optional. Adds “Run with BTP destination (cf ssh)” to F5

Both scripts only use Node.js built-ins, so there is nothing to install. Save them in a scripts/ folder of your project.

scripts/btp-proxy.js

#!/usr/bin/env node
// Local HTTP proxy to an on-premise system, through a BTP destination.
// Reads the destination + connectivity bindings of a CF app, opens a cf ssh
// tunnel to the connectivity proxy and forwards every request through it.
const http = require('http');
const net = require('net');
const { spawn, execFileSync } = require('child_process');

const APP = process.env.BTP_APP || 'my-tunnel-app';
const DESTINATION = process.env.BTP_DESTINATION || 'S4H_CLNT100';
const PROXY_PORT = Number(process.env.BTP_PROXY_PORT || 5050);
const TUNNEL_PORT = Number(process.env.BTP_TUNNEL_PORT || 20003);
const DEBUG = process.env.BTP_PROXY_DEBUG === '1';
const BASIC = process.env.BTP_PROXY_USER
  ? 'Basic ' + Buffer.from(`${process.env.BTP_PROXY_USER}:${process.env.BTP_PROXY_PWD}`).toString('base64')
  : null;
const PRIME_COOKIES = ['sap-usercontext', 'sap-ssolist'];

const log = (...a) => console.log('[btp-proxy]', ...a);
const cf = (args) => execFileSync('cf', args, { encoding: 'utf8' });

// 1. Service bindings of the CF app: read in memory, never written to disk
function readBindings() {
  const guid = cf(['app', APP, '--guid']).trim();
  const env = JSON.parse(cf(['curl', `/v3/apps/${guid}/env`]));
  const vcap = env.system_env_json.VCAP_SERVICES || {};
  const dest = vcap.destination?.[0]?.credentials;
  const conn = vcap.connectivity?.[0]?.credentials;
  if (!dest || !conn) {
    throw new Error(`App ${APP} must be bound to a destination and a connectivity service`);
  }
  return { dest, conn };
}

// 2. OAuth client-credentials tokens, cached until one minute before expiry
const tokens = new Map();
async function token(creds) {
  const hit = tokens.get(creds.clientid);
  if (hit && hit.exp > Date.now() + 60_000) return hit.value;
  const res = await fetch(`${creds.url}/oauth/token`, {
    method: 'POST',
    headers: {
      Authorization: 'Basic ' + Buffer.from(`${creds.clientid}:${creds.clientsecret}`).toString('base64'),
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: 'grant_type=client_credentials'
  });
  if (!res.ok) throw new Error(`Token request to ${creds.url} failed: ${res.status}`);
  const json = await res.json();
  tokens.set(creds.clientid, { value: json.access_token, exp: Date.now() + json.expires_in * 1000 });
  return json.access_token;
}

// 3. Destination lookup: virtual host, sap-client and Cloud Connector location
async function readDestination(dest) {
  const url = `${dest.uri}/destination-configuration/v1/destinations/${encodeURIComponent(DESTINATION)}`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${await token(dest)}` } });
  if (!res.ok) throw new Error(`Destination ${DESTINATION} not found (${res.status})`);
  const { destinationConfiguration: c } = await res.json();
  if (c.ProxyType !== 'OnPremise') throw new Error(`Destination ${DESTINATION} is not an OnPremise destination`);
  return { origin: new URL(c.URL).origin, client: c['sap-client'], location: c.CloudConnectorLocationId };
}

// 4. cf ssh port forward: localhost:TUNNEL_PORT -> connectivity proxy inside CF
function openTunnel(conn) {
  const forward = `127.0.0.1:${TUNNEL_PORT}:${conn.onpremise_proxy_host}:${conn.onpremise_proxy_http_port}`;
  const ssh = spawn('cf', ['ssh', APP, '-N', '-T', '-L', forward], { stdio: ['ignore', 'inherit', 'inherit'] });
  ssh.on('exit', (code) => { log(`tunnel closed (${code})`); process.exit(1); });
  return ssh;
}

function waitForPort(port, timeoutMs = 30_000) {
  const until = Date.now() + timeoutMs;
  return new Promise((resolve, reject) => {
    (function attempt() {
      const s = net.connect(port, '127.0.0.1', () => { s.end(); resolve(); });
      s.on('error', () => Date.now() > until
        ? reject(new Error(`tunnel not reachable on port ${port}`))
        : setTimeout(attempt, 500));
    })();
  });
}

function portInUse(port) {
  return new Promise((resolve) => {
    const s = net.connect(port, '127.0.0.1', () => { s.end(); resolve(true); });
    s.on('error', () => resolve(false));
  });
}

// One request through the tunnel: absolute URI + connectivity headers
function forward({ method, target, headers }, conn, location) {
  return token(conn).then((jwt) => http.request({
    host: '127.0.0.1',
    port: TUNNEL_PORT,
    method,
    path: target.href,
    headers: {
      ...headers,
      host: target.host,
      'proxy-authorization': `Bearer ${jwt}`,
      ...(location && { 'sap-connectivity-scc-location_id': location })
    }
  }));
}

// Some backends only accept a basic logon after a first 401 round trip that
// sets sap-usercontext / sap-ssolist. Browsers do that naturally, fiori deploy
// does not, so the proxy collects these cookies once and adds them itself.
let primed = null;
async function primeCookies(target, conn, location) {
  if (primed) return primed;
  const req = await forward({ method: 'GET', target, headers: {} }, conn, location);
  primed = await new Promise((resolve, reject) => {
    req.on('response', (res) => {
      res.resume();
      const cookies = (res.headers['set-cookie'] || [])
        .map((c) => c.split(';')[0])
        .filter((c) => PRIME_COOKIES.includes(c.split('=')[0]));
      resolve(cookies);
    });
    req.on('error', reject);
    req.end();
  });
  return primed;
}

async function main() {
  if (await portInUse(TUNNEL_PORT)) {
    throw new Error(`Port ${TUNNEL_PORT} is already in use - close the other tunnel or set BTP_TUNNEL_PORT`);
  }
  const { dest, conn } = readBindings();
  const backend = await readDestination(dest);
  log(`destination ${DESTINATION} -> ${backend.origin} (client ${backend.client || '-'}, location ${backend.location || 'default'})`);

  const ssh = openTunnel(conn);
  const stop = () => { ssh.removeAllListeners('exit'); ssh.kill(); process.exit(0); };
  process.on('SIGINT', stop);
  process.on('SIGTERM', stop);
  await waitForPort(TUNNEL_PORT);

  http.createServer(async (req, res) => {
    if (req.url === '/__btp-proxy/health') return res.end('ok');
    if (req.url === '/__btp-proxy/shutdown' && req.method === 'POST') { res.end('bye'); return stop(); }
    try {
      const target = new URL(req.url, backend.origin);
      if (backend.client && !target.searchParams.has('sap-client')) {
        target.searchParams.set('sap-client', backend.client);
      }
      const headers = { ...req.headers };
      delete headers['proxy-authorization'];
      if (BASIC && !headers.authorization) headers.authorization = BASIC;
      if (headers.authorization && !/sap-usercontext=/.test(headers.cookie || '')) {
        const cookies = await primeCookies(target, conn, backend.location);
        if (cookies.length) headers.cookie = [headers.cookie, ...cookies].filter(Boolean).join('; ');
      }
      const upstream = await forward({ method: req.method, target, headers }, conn, backend.location);
      upstream.on('response', (up) => {
        if (DEBUG) {
          const names = (up.headers['set-cookie'] || []).map((c) => c.split('=')[0]);
          log(req.method, req.url, up.statusCode, names.length ? `cookies: ${names.join(', ')}` : '');
        }
        // Keep the browser on localhost: relative redirects, no Secure flag over plain http
        if (up.headers.location?.startsWith(backend.origin)) {
          up.headers.location = up.headers.location.slice(backend.origin.length);
        }
        if (up.headers['set-cookie']) {
          up.headers['set-cookie'] = up.headers['set-cookie'].map((c) => c.replace(/;s*secure/gi, ''));
        }
        res.writeHead(up.statusCode, up.headers);
        up.pipe(res);
      });
      upstream.on('error', (e) => { res.writeHead(502); res.end(`btp-proxy: ${e.message}`); });
      req.pipe(upstream);
    } catch (e) {
      res.writeHead(502);
      res.end(`btp-proxy: ${e.message}`);
    }
  }).listen(PROXY_PORT, '127.0.0.1', () => log(`ready on http://127.0.0.1:${PROXY_PORT}`));
}

main().catch((e) => { console.error('[btp-proxy]', e.message); process.exit(1); });

scripts/btp.js

#!/usr/bin/env node
// One-shot commands: start the app or deploy it through btp-proxy.
// Reuses a proxy that is already running, otherwise starts one and stops it afterwards.
const path = require('path');
const { spawn } = require('child_process');

const PORT = Number(process.env.BTP_PROXY_PORT || 5050);
const PROXY = `http://127.0.0.1:${PORT}/__btp-proxy`;
const [command, ...flags] = process.argv.slice(2);

async function proxyRunning() {
  try { return (await fetch(`${PROXY}/health`)).ok; } catch { return false; }
}

function startProxy() {
  return new Promise((resolve, reject) => {
    const child = spawn(process.execPath, [path.join(__dirname, 'btp-proxy.js')], { stdio: ['ignore', 'pipe', 'inherit'] });
    child.stdout.on('data', (chunk) => {
      process.stdout.write(chunk);
      if (chunk.toString().includes('ready on')) resolve(child);
    });
    child.on('exit', () => reject(new Error('proxy exited during startup - check cf login / cf target')));
  });
}

async function stopProxy() {
  try { await fetch(`${PROXY}/shutdown`, { method: 'POST' }); } catch { /* already gone */ }
}

function run(cmd) {
  return new Promise((resolve) => {
    spawn(cmd, { stdio: 'inherit', shell: true }).on('exit', (code) => resolve(code ?? 1));
  });
}

async function main() {
  const ownProxy = !(await proxyRunning());
  if (ownProxy) await startProxy();
  else console.log(`[btp] reusing btp-proxy on port ${PORT}`);

  process.on('SIGINT', async () => { if (ownProxy) await stopProxy(); process.exit(130); });

  let code;
  if (command === 'start') {
    code = await run('npx fiori run --config ui5-btp.yaml --open "test/flp.html#app-preview"');
  } else if (command === 'deploy') {
    const test = flags.includes('--test') ? ' --testMode true' : '';
    code = await run('npx ui5 build --config ui5-deploy-btp.yaml --clean-dest');
    if (code === 0) code = await run(`npx dotenv -- fiori deploy --config ui5-deploy-btp.yaml --yes${test}`);
  } else {
    console.error('usage: node scripts/btp.js start | deploy [--test]');
    code = 2;
  }

  if (ownProxy) await stopProxy();
  process.exit(code);
}

main().catch((e) => { console.error('[btp]', e.message); process.exit(1); });

A detail that matters on Windows: btp.js stops the proxy with an HTTP call (POST /__btp-proxy/shutdown) instead of killing the process. On Windows, killing a Node process skips its exit handlers, and you would end up with an orphan cf ssh holding port 20003.

2. ui5-btp.yaml: run configuration

Copy your ui5.yaml to ui5-btp.yaml and point the backend to the proxy. Remove the destination: and client: lines: the proxy adds the client from the destination.

    - name: fiori-tools-proxy
      afterMiddleware: compression
      configuration:
        backend:
          - path: /sap
            url: http://127.0.0.1:5050   # btp-proxy

3. ui5-deploy-btp.yaml: deploy configuration

Copy your ui5-deploy.yaml to ui5-deploy-btp.yaml, set the target URL to the proxy and remove destination:. Keep your client, package and transport:

    - name: deploy-to-abap
      configuration:
        target:
          url: http://127.0.0.1:5050   # btp-proxy
          client: "100"
        credentials:
          username: env:USR
          password: env:SAP_PWD
        app:
          name: ZMY_APP
          package: ZMY_PACKAGE
          transport: S4HK900123

Keep the original ui5.yaml and ui5-deploy.yaml untouched, so BAS keeps working for colleagues who prefer it.

4. package.json

npm install --save-dev dotenv-cli
"scripts": {
  "start-btp": "node scripts/btp.js start",
  "deploy-btp": "node scripts/btp.js deploy",
  "deploy-btp-test": "node scripts/btp.js deploy --test"
}

btp.js opens test/flp.html#app-preview. Adapt the fiori run line if your app starts elsewhere.

5. .env: deploy credentials

USR=<your SAP user>
SAP_PWD=<your password in the DEPLOY client>

.env must be in .gitignore. Variables set in your terminal take precedence over .env, so you can also skip the file and set them per session.

6. Optional: F5 in VS Code

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run with BTP destination (cf ssh)",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "start-btp"],
      "console": "integratedTerminal"
    }
  ]
}

Daily use

blog-4-commands.png

I want to… Run Notes

Run the app npm run start-btp or F5 The browser opens localhost:8081. Log in with your SAP user of the destination’s client. Ctrl+C stops everything.
Try a deploy npm run deploy-btp-test Test mode, nothing is written
Deploy npm run deploy-btp Works whether the app is running in another terminal or not

Configuration

All optional, as environment variables (terminal or .env😞

Variable Default Purpose

BTP_APP my-tunnel-app CF app whose destination + connectivity bindings are used (SSH enabled)
BTP_DESTINATION S4H_CLNT100 Destination used by the proxy
BTP_PROXY_PORT 5050 Local proxy port
BTP_TUNNEL_PORT 20003 Local end of the cf ssh tunnel
BTP_PROXY_USER / BTP_PROXY_PWD – Log in automatically instead of the browser prompt
BTP_PROXY_DEBUG – 1 logs every request, its status and cookie names (never values)

Switching backend or client is just BTP_DESTINATION=S4H_CLNT200 npm run start-btp.

Troubleshooting

Symptom Fix

proxy exited during startup Your CF session expired: cf login / cf target
App … must be bound to a destination and a connectivity service Set BTP_APP to an app that has both bindings
Port 20003 is already in use A tunnel from another tool is still open. Close it or set BTP_TUNNEL_PORT
403 Access denied to resource … expose the resource in your cloud connector That URL path is not exposed in the Cloud Connector. Ask the Cloud Connector administrators
Deploy ends with an authentication error Check USR / SAP_PWD. It must be the password of the deploy client (client: in ui5-deploy-btp.yaml)
Token request … failed: 401 The binding uses X.509 credentials instead of a client secret. These scripts expect clientsecret. Rebind with a secret, or extend token() for mTLS

What about security?

A fair question, and one to discuss with your BTP administrators before using this. My view:

  • It doesn’t open anything new. Anyone who can cf ssh into an app and read its environment can already do all of this by hand. The scripts need exactly the same SpaceDeveloper rights, and they only automate those steps.
  • Nothing is stored. The binding credentials and tokens live in memory for the lifetime of the proxy, with no service key and no file.
  • The Cloud Connector stays in charge. Only the hosts and paths exposed in the Cloud Connector are reachable. Everything else gets a 403, exactly as from BAS.
  • The backend still authenticates you with your own SAP user.
  • Localhost only. The proxy listens on 127.0.0.1, so other machines on your network can’t use it.
  • It is a development tool. Use it on development and test subaccounts, not as a production integration path.

Not only for Fiori: CAP and friends

The proxy doesn’t know about Fiori at all. It is an HTTP endpoint on 127.0.0.1:5050 that ends up in your backend. For a CAP project consuming an on-premise OData service, point the remote service to it in a development profile and start the proxy with node scripts/btp-proxy.js:

"cds": {
  "requires": {
    "API_BUSINESS_PARTNER": {
      "kind": "odata-v2",
      "model": "srv/external/API_BUSINESS_PARTNER",
      "[development]": {
        "credentials": {
          "url": "http://127.0.0.1:5050/sap/opu/odata/sap/API_BUSINESS_PARTNER"
        }
      }
    }
  }
}

Combined with BTP_PROXY_USER / BTP_PROXY_PWD, cds watch talks to the real backend from your laptop. The same goes for Postman, Bruno, curl or a quick test script.

Wrapping up

What I like most about this setup is that it removes a whole category of “why is it hanging today?” problems. My editor, my Node version, my disk and my extensions are all local and fast. BTP provides only the one thing my laptop can’t do on its own: a trusted path through the Cloud Connector.

Once again, BAS is a good tool and I still use it when it makes sense. But when the only reason to open it is connectivity, cf ssh is all you need.

This is my first post, so I would really appreciate your feedback. Tell me if this works in your landscape, if you hit other gotchas, or if you know a simpler way. See you in the comments!

“}]] 

  Read More Technology Blog Posts by Members articles 

#abap

By ali

Leave a Reply