@qelos/plugin-netlify-api 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Netlify build plugin for Qelos frontend apps: proxies `/api/*` to your Qelos API
4
4
 
5
5
  ## Setup
6
6
 
7
- In your site’s `netlify.toml`, add the plugin next to your existing `[build]` / `[build.environment]` (same idea as a manual `[functions]` + `[[redirects]]` + `api-proxy.ts`, but without checking in the function or redirect yourself):
7
+ In your site’s `netlify.toml`, add the plugin next to your existing `[build]` / `[build.environment]`:
8
8
 
9
9
  ```toml
10
10
  [build]
@@ -12,21 +12,36 @@ In your site’s `netlify.toml`, add the plugin next to your existing `[build]`
12
12
  publish = "dist"
13
13
 
14
14
  [build.environment]
15
- # Used by the injected proxy at runtime (and by your build if you read it in app config)
16
15
  QELOS_API_IP = "http://159.203.152.168"
17
16
 
18
17
  [[plugins]]
19
18
  package = "@qelos/plugin-netlify-api"
20
19
  ```
21
20
 
22
- Minimal variant (defaults API URL inside the plugin if you omit env):
21
+ **Nuxt / static frameworks (required):** Netlify evaluates `dist/_redirects` **before** `netlify.toml`. Nitro’s `netlify-static` preset emits a catch‑all `/*` rule, so the API rule must be **first** in `_redirects`. Add an npm **`postbuild`** script (runs after `nuxt build` when Netlify runs `npm run build`):
23
22
 
24
- ```toml
25
- [[plugins]]
26
- package = "@qelos/plugin-netlify-api"
23
+ ```json
24
+ {
25
+ "scripts": {
26
+ "build": "nuxt build",
27
+ "postbuild": "qelos-netlify-patch-redirects"
28
+ }
29
+ }
27
30
  ```
28
31
 
29
- Optional: set the API URL (default `http://159.203.152.168`) and/or enable bypass-admin header:
32
+ If your publish directory is not `dist`, set `QELOS_NETLIFY_PUBLISH` to that folder name (relative to the app root).
33
+
34
+ ## How it proxies (default)
35
+
36
+ By default the plugin uses **Netlify’s CDN rewrite** to your Qelos API (same pattern as [Netlify’s proxy docs](https://docs.netlify.com/manage/routing/redirects/rewrites-proxies/#proxy-to-another-service)):
37
+
38
+ - `/api/*` → `${QELOS_API_IP origin}/api/:splat` with status `200` and `force`
39
+
40
+ So `/api/me` is proxied to `http://your-api-host/api/me` **at the edge**, without a serverless function. That avoids fragile `/.netlify/functions/...` rewrites and matches Nuxt’s `_redirects` behavior.
41
+
42
+ Optional: **`use_function_proxy = true`** — uses the bundled `qelos-api-proxy` serverless function instead (for cases where you must run Node logic on each request).
43
+
44
+ Optional plugin inputs:
30
45
 
31
46
  ```toml
32
47
  [[plugins]]
@@ -35,17 +50,17 @@ Optional: set the API URL (default `http://159.203.152.168`) and/or enable bypas
35
50
  [plugins.inputs]
36
51
  api_url = "http://159.203.152.168"
37
52
  bypass_admin = true
53
+ use_function_proxy = false
38
54
  ```
39
55
 
40
- - **api_url** — Target for the `/api/*` proxy. You can also set **`QELOS_API_IP`** or **`API_HOST`** in `[build.environment]` or in the Netlify UI (same role as `API_HOST` in a hand-written `netlify/functions/api-proxy.ts`).
41
- - **bypass_admin** — If `true`, the proxy adds the header `x-bypass-admin: true` to every request so that admin users are scoped as regular users. See root docs: `documentation/docs/plugins/api-proxy.md` and `documentation/docs/sdk/token_refresh.md`.
56
+ - **api_url** — Qelos API base URL (same as `QELOS_API_IP` / `API_HOST`).
57
+ - **bypass_admin** — If `true`, adds header `x-bypass-admin: true` on proxied requests (CDN and function modes).
58
+ - **use_function_proxy** — If `true`, deploy the bundled `qelos-api-proxy` function and rewrite to it instead of the CDN proxy.
42
59
 
43
60
  ## What it does
44
61
 
45
- - **Environment:** Sets `QELOS_API_IP` for the build (and for the proxy function at runtime).
46
- - **Redirect:** Adds a rewrite in `netlify.toml` and, **after the site build**, prepends the same rule to **`publish/_redirects`** when that file exists. Netlify applies `_redirects` **before** `netlify.toml`; frameworks (e.g. Nuxt `netlify-static`) often emit `/* → /404.html` first, which would otherwise match `/api/*` and never reach the proxy.
47
- - **Function:** Copies `qelos-api-proxy.ts` from the package into your Netlify **functions directory** (default `netlify/functions`, or `[functions] directory` in `netlify.toml`) during `onPreBuild`, so the bundler sees a normal function file. Forwards requests using `QELOS_API_IP` / `API_HOST` (full URL or hostname).
48
-
49
- No need to add redirects or commit the proxy yourself; the plugin writes it each build. Optional: add `netlify/functions/qelos-api-proxy.ts` to `.gitignore` if you run local Netlify builds and do not want the copy tracked.
62
+ - **Environment:** Sets `QELOS_API_IP`, `QELOS_NETLIFY_PROXY_MODE` (`cdn` or `function`).
63
+ - **Redirects:** Injects the `/api/*` rewrite into `netlify.toml` (and **`postbuild`** rewrites `publish/_redirects` so Nuxt’s catch‑all does not win).
64
+ - **Function mode only:** Copies `qelos-api-proxy.ts` into your Netlify functions directory.
50
65
 
51
- **Parity with a manual Netlify function** (e.g. `[[redirects]]` → `/.netlify/functions/api-proxy` and a function using `process.env.API_HOST`): use this plugin instead of those entries; set `QELOS_API_IP` or `API_HOST` to the same value you used for `API_HOST`, and set `bypass_admin = true` under `[plugins.inputs]` if your old proxy always sent `x-bypass-admin: true`.
66
+ No need to commit redirects yourself; the plugin and `postbuild` maintain them each build.
package/index.js CHANGED
@@ -1,73 +1,111 @@
1
1
  const fs = require('node:fs/promises');
2
2
  const path = require('node:path');
3
3
 
4
- const DEFAULT_API_URL = 'http://159.203.152.168';
4
+ const { patchRedirects } = require('./scripts/patch-redirects.cjs');
5
5
 
6
- /** Must appear before any `/*` rule in publish/_redirects — Netlify evaluates that file before netlify.toml. */
7
- const REDIRECTS_FILE_LINE =
8
- '/api/* /.netlify/functions/qelos-api-proxy 200!\n';
6
+ const DEFAULT_API_URL = 'http://159.203.152.168';
9
7
 
10
8
  function resolveFunctionsDirectory(netlifyConfig) {
11
9
  const dir = netlifyConfig.functions?.directory ?? 'netlify/functions';
12
10
  return path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
13
11
  }
14
12
 
13
+ function resolvePublishDir(constants, netlifyConfig) {
14
+ if (constants?.PUBLISH_DIR) {
15
+ return constants.PUBLISH_DIR;
16
+ }
17
+ const p = netlifyConfig?.build?.publish;
18
+ if (!p) {
19
+ return path.join(process.cwd(), 'dist');
20
+ }
21
+ return path.isAbsolute(p) ? p : path.join(process.cwd(), p);
22
+ }
23
+
24
+ function upstreamOrigin(apiUrl) {
25
+ const s = String(apiUrl ?? '').trim().replace(/\/$/, '');
26
+ if (!s) {
27
+ return new URL(DEFAULT_API_URL).origin;
28
+ }
29
+ if (/^https?:\/\//i.test(s)) {
30
+ return new URL(s).origin;
31
+ }
32
+ return `http://${s}`;
33
+ }
34
+
35
+ function stripApiRedirects(redirects) {
36
+ return (redirects || []).filter((r) => r && r.from !== '/api/*');
37
+ }
38
+
15
39
  module.exports = {
16
40
  async onPreBuild({ netlifyConfig, inputs }) {
17
41
  const apiUrl =
18
42
  inputs.api_url ?? process.env.QELOS_API_IP ?? DEFAULT_API_URL;
19
43
  const bypassAdmin =
20
44
  inputs.bypass_admin === true || inputs.bypass_admin === 'true';
45
+ const useFunctionProxy =
46
+ inputs.use_function_proxy === true || inputs.use_function_proxy === 'true';
21
47
 
22
- // Ensure build.environment exists
23
48
  if (!netlifyConfig.build.environment) {
24
49
  netlifyConfig.build.environment = {};
25
50
  }
26
51
  netlifyConfig.build.environment.QELOS_API_IP = apiUrl;
52
+ netlifyConfig.build.environment.QELOS_NETLIFY_PROXY_MODE = useFunctionProxy
53
+ ? 'function'
54
+ : 'cdn';
27
55
  if (bypassAdmin) {
28
56
  netlifyConfig.build.environment.QELOS_BYPASS_ADMIN_HEADER = 'true';
29
57
  }
30
58
 
31
- if (!netlifyConfig.functions) {
32
- netlifyConfig.functions = { directory: 'netlify/functions' };
59
+ if (!netlifyConfig.redirects) {
60
+ netlifyConfig.redirects = [];
33
61
  }
62
+ netlifyConfig.redirects = stripApiRedirects(netlifyConfig.redirects);
34
63
 
35
- // Copy the proxy into the site's functions dir so Netlify bundles it like any
36
- // checked-in function (utils.functions.add from node_modules is unreliable for .ts).
37
- const destDir = resolveFunctionsDirectory(netlifyConfig);
38
- const destFile = path.join(destDir, 'qelos-api-proxy.ts');
39
- const srcFile = path.join(__dirname, 'functions', 'qelos-api-proxy.ts');
40
- await fs.mkdir(destDir, { recursive: true });
41
- await fs.copyFile(srcFile, destFile);
64
+ if (useFunctionProxy) {
65
+ if (!netlifyConfig.functions) {
66
+ netlifyConfig.functions = { directory: 'netlify/functions' };
67
+ }
68
+ const destDir = resolveFunctionsDirectory(netlifyConfig);
69
+ const destFile = path.join(destDir, 'qelos-api-proxy.ts');
70
+ const srcFile = path.join(__dirname, 'functions', 'qelos-api-proxy.ts');
71
+ await fs.mkdir(destDir, { recursive: true });
72
+ await fs.copyFile(srcFile, destFile);
42
73
 
43
- // Add redirect: /api/* -> Netlify function (proxy)
44
- if (!netlifyConfig.redirects) {
45
- netlifyConfig.redirects = [];
74
+ netlifyConfig.redirects.unshift({
75
+ from: '/api/*',
76
+ to: '/.netlify/functions/qelos-api-proxy',
77
+ status: 200,
78
+ force: true,
79
+ });
80
+ } else {
81
+ const origin = upstreamOrigin(apiUrl);
82
+ const redirect = {
83
+ from: '/api/*',
84
+ to: `${origin}/api/:splat`,
85
+ status: 200,
86
+ force: true,
87
+ };
88
+ if (bypassAdmin) {
89
+ redirect.headers = { 'X-Bypass-Admin': 'true' };
90
+ }
91
+ netlifyConfig.redirects.unshift(redirect);
46
92
  }
47
- netlifyConfig.redirects.push({
48
- from: '/api/*',
49
- to: '/.netlify/functions/qelos-api-proxy',
50
- status: 200,
51
- force: true,
52
- });
53
93
  },
54
94
 
55
- /**
56
- * Nuxt/static generators emit `dist/_redirects` with `/* → 404` first. Netlify runs
57
- * `_redirects` rules before `netlify.toml`, so that catch-all wins unless `/api/*` is
58
- * prepended here (after the framework build).
59
- */
60
- async onPostBuild({ constants }) {
61
- const publishDir = constants.PUBLISH_DIR;
62
- if (!publishDir) return;
63
- const redirectsPath = path.join(publishDir, '_redirects');
64
- let existing = '';
65
- try {
66
- existing = await fs.readFile(redirectsPath, 'utf8');
67
- } catch (e) {
68
- if (e.code !== 'ENOENT') throw e;
69
- }
70
- if (existing.includes('qelos-api-proxy')) return;
71
- await fs.writeFile(redirectsPath, REDIRECTS_FILE_LINE + existing, 'utf8');
95
+ async onPostBuild({ constants, netlifyConfig, inputs }) {
96
+ const publishDir = resolvePublishDir(constants, netlifyConfig);
97
+ const apiUrl =
98
+ inputs?.api_url ??
99
+ netlifyConfig?.build?.environment?.QELOS_API_IP ??
100
+ process.env.QELOS_API_IP ??
101
+ DEFAULT_API_URL;
102
+ const mode =
103
+ netlifyConfig?.build?.environment?.QELOS_NETLIFY_PROXY_MODE === 'function'
104
+ ? 'function'
105
+ : 'cdn';
106
+ const changed = patchRedirects(publishDir, apiUrl, mode);
107
+ console.log(
108
+ `[@qelos/plugin-netlify-api] onPostBuild: ${changed ? 'patched' : 'unchanged'} ${path.join(publishDir, '_redirects')} (${mode})`,
109
+ );
72
110
  },
73
111
  };
package/manifest.yml CHANGED
@@ -8,3 +8,7 @@ inputs:
8
8
  required: false
9
9
  description: "If true, the proxy adds header x-bypass-admin: true to every request (so admins are scoped as regular users)."
10
10
  default: false
11
+ - name: use_function_proxy
12
+ required: false
13
+ description: "If true, use the bundled serverless function instead of Netlify CDN proxy to api_url (default is CDN rewrite to origin/api/:splat)."
14
+ default: false
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@qelos/plugin-netlify-api",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "Netlify build plugin: proxy /api/* to Qelos API and inject config",
5
5
  "main": "index.js",
6
+ "bin": {
7
+ "qelos-netlify-patch-redirects": "./scripts/patch-redirects.cjs"
8
+ },
6
9
  "files": [
7
10
  "index.js",
8
11
  "manifest.yml",
9
- "functions"
12
+ "functions",
13
+ "scripts"
10
14
  ],
11
15
  "author": "David Meir-Levy <davidmeirlevy@gmail.com>",
12
16
  "license": "MIT",
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prepend a forced `/api/*` rewrite as the first line of publish/_redirects.
6
+ * Netlify evaluates this file before netlify.toml; Nuxt `netlify-static` adds
7
+ * `/* → /404.html` — that must come after `/api/*`.
8
+ *
9
+ * Default: CDN proxy to `${QELOS_API_IP}/api/:splat` (Netlify’s documented pattern).
10
+ * Set QELOS_NETLIFY_PROXY_MODE=function to use the serverless function instead.
11
+ */
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const DEFAULT_API = 'http://159.203.152.168';
17
+
18
+ function upstreamOrigin(apiUrl) {
19
+ const s = String(apiUrl ?? '').trim().replace(/\/$/, '');
20
+ if (!s) {
21
+ return new URL(DEFAULT_API).origin;
22
+ }
23
+ if (/^https?:\/\//i.test(s)) {
24
+ return new URL(s).origin;
25
+ }
26
+ return `http://${s}`;
27
+ }
28
+
29
+ /**
30
+ * @param {string} [apiUrl]
31
+ * @param {'cdn'|'function'} [mode]
32
+ */
33
+ function buildRedirectLine(apiUrl, mode) {
34
+ const m = mode === 'function' ? 'function' : 'cdn';
35
+ if (m === 'function') {
36
+ return '/api/* /.netlify/functions/qelos-api-proxy 200!\n';
37
+ }
38
+ const origin = upstreamOrigin(apiUrl ?? process.env.QELOS_API_IP);
39
+ return `/api/* ${origin}/api/:splat 200!\n`;
40
+ }
41
+
42
+ /**
43
+ * Drop existing /api/* lines so we don’t stack stale rules when the target URL changes.
44
+ * @param {string} content
45
+ */
46
+ function stripApiRedirectLines(content) {
47
+ return content
48
+ .split('\n')
49
+ .filter((line) => {
50
+ const t = line.trim();
51
+ if (!t || t.startsWith('#')) return true;
52
+ if (t.startsWith('/api/*')) return false;
53
+ return true;
54
+ })
55
+ .join('\n');
56
+ }
57
+
58
+ /**
59
+ * @param {string} publishDir absolute path to deploy root (e.g. …/dist)
60
+ * @param {string} [apiUrl] same as QELOS_API_IP
61
+ * @param {'cdn'|'function'} [mode]
62
+ * @returns {boolean} true if the file was written
63
+ */
64
+ function patchRedirects(publishDir, apiUrl, mode) {
65
+ if (!publishDir) return false;
66
+ const redirectsPath = path.join(publishDir, '_redirects');
67
+ let existing = '';
68
+ try {
69
+ existing = fs.readFileSync(redirectsPath, 'utf8');
70
+ } catch (e) {
71
+ if (e.code !== 'ENOENT') throw e;
72
+ }
73
+ const line = buildRedirectLine(apiUrl, mode);
74
+ const rest = stripApiRedirectLines(existing);
75
+ const next = line + (rest ? rest + (rest.endsWith('\n') ? '' : '\n') : '');
76
+ if (next === existing) {
77
+ return false;
78
+ }
79
+ fs.writeFileSync(redirectsPath, next, 'utf8');
80
+ return true;
81
+ }
82
+
83
+ function resolvePublishDirFromEnv() {
84
+ const rel = process.env.QELOS_NETLIFY_PUBLISH || 'dist';
85
+ return path.isAbsolute(rel) ? rel : path.join(process.cwd(), rel);
86
+ }
87
+
88
+ if (require.main === module) {
89
+ const abs = resolvePublishDirFromEnv();
90
+ const mode =
91
+ process.env.QELOS_NETLIFY_PROXY_MODE === 'function' ? 'function' : 'cdn';
92
+ const apiUrl = process.env.QELOS_API_IP;
93
+ const changed = patchRedirects(abs, apiUrl, mode);
94
+ const target = path.join(abs, '_redirects');
95
+ const kind = mode === 'function' ? 'function' : 'CDN';
96
+ if (changed) {
97
+ console.log(
98
+ `[qelos-netlify-patch-redirects] prepended /api/* (${kind}) → ${target}`,
99
+ );
100
+ } else {
101
+ console.log(`[qelos-netlify-patch-redirects] up to date → ${target}`);
102
+ }
103
+ }
104
+
105
+ module.exports = {
106
+ patchRedirects,
107
+ buildRedirectLine,
108
+ upstreamOrigin,
109
+ stripApiRedirectLines,
110
+ };