@qelos/plugin-netlify-api 1.0.5 → 1.1.1
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 +20 -18
- package/functions/qelos-api-proxy.ts +45 -4
- package/index.js +62 -26
- package/manifest.yml +4 -0
- package/package.json +1 -1
- package/scripts/patch-redirects.cjs +69 -15
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]
|
|
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,14 +12,13 @@ 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
|
-
**Nuxt / static frameworks (required):** Netlify evaluates `dist/_redirects` **before** `netlify.toml`. Nitro’s `netlify-static` preset emits a catch‑all `/*` rule
|
|
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
23
|
```json
|
|
25
24
|
{
|
|
@@ -30,16 +29,19 @@ In your site’s `netlify.toml`, add the plugin next to your existing `[build]`
|
|
|
30
29
|
}
|
|
31
30
|
```
|
|
32
31
|
|
|
33
|
-
If your publish directory is not `dist`, set `QELOS_NETLIFY_PUBLISH` to that folder name (relative to the app root)
|
|
32
|
+
If your publish directory is not `dist`, set `QELOS_NETLIFY_PUBLISH` to that folder name (relative to the app root).
|
|
34
33
|
|
|
35
|
-
|
|
34
|
+
## How it proxies (default)
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
[[plugins]]
|
|
39
|
-
package = "@qelos/plugin-netlify-api"
|
|
40
|
-
```
|
|
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)):
|
|
41
37
|
|
|
42
|
-
|
|
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:
|
|
43
45
|
|
|
44
46
|
```toml
|
|
45
47
|
[[plugins]]
|
|
@@ -48,17 +50,17 @@ Optional: set the API URL (default `http://159.203.152.168`) and/or enable bypas
|
|
|
48
50
|
[plugins.inputs]
|
|
49
51
|
api_url = "http://159.203.152.168"
|
|
50
52
|
bypass_admin = true
|
|
53
|
+
use_function_proxy = false
|
|
51
54
|
```
|
|
52
55
|
|
|
53
|
-
- **api_url** —
|
|
54
|
-
- **bypass_admin** — If `true`,
|
|
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.
|
|
55
59
|
|
|
56
60
|
## What it does
|
|
57
61
|
|
|
58
|
-
- **Environment:** Sets `QELOS_API_IP`
|
|
59
|
-
- **
|
|
60
|
-
- **Function:** Copies `qelos-api-proxy.ts`
|
|
61
|
-
|
|
62
|
-
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.
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
No need to commit redirects yourself; the plugin and `postbuild` maintain them each build.
|
|
@@ -29,12 +29,18 @@ const ADD_BYPASS_ADMIN_HEADER =
|
|
|
29
29
|
|
|
30
30
|
export const handler: Handler = (event) =>
|
|
31
31
|
new Promise((resolve) => {
|
|
32
|
-
// Build forwarded headers
|
|
32
|
+
// Build forwarded headers. Upstream Qelos expects Host = public app domain (e.g. app.auto.qelos.io),
|
|
33
|
+
// not the raw API IP — use the visitor-facing host (case-insensitive headers; rawUrl fallback).
|
|
33
34
|
const headers: Record<string, string> = {};
|
|
34
|
-
for (const [key, val] of Object.entries(event.headers)) {
|
|
35
|
-
if (val
|
|
35
|
+
for (const [key, val] of Object.entries(event.headers ?? {})) {
|
|
36
|
+
if (val == null || val === "") continue;
|
|
37
|
+
if (Array.isArray(val)) {
|
|
38
|
+
headers[key] = val.join(", ");
|
|
39
|
+
} else if (typeof val === "string") {
|
|
40
|
+
headers[key] = val;
|
|
41
|
+
}
|
|
36
42
|
}
|
|
37
|
-
headers["host"] = event
|
|
43
|
+
headers["host"] = publicHostForUpstream(event);
|
|
38
44
|
if (ADD_BYPASS_ADMIN_HEADER) {
|
|
39
45
|
headers["x-bypass-admin"] = "true";
|
|
40
46
|
}
|
|
@@ -86,3 +92,38 @@ export const handler: Handler = (event) =>
|
|
|
86
92
|
if (body) proxyReq.write(body);
|
|
87
93
|
proxyReq.end();
|
|
88
94
|
});
|
|
95
|
+
|
|
96
|
+
function publicHostForUpstream(event: {
|
|
97
|
+
headers: Record<string, string | string[] | undefined>;
|
|
98
|
+
rawUrl: string;
|
|
99
|
+
}): string {
|
|
100
|
+
const forwarded = pickHeader(event.headers, "x-forwarded-host");
|
|
101
|
+
if (forwarded) {
|
|
102
|
+
return forwarded.split(",")[0].trim();
|
|
103
|
+
}
|
|
104
|
+
const h = pickHeader(event.headers, "host");
|
|
105
|
+
if (h) {
|
|
106
|
+
return h;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return new URL(event.rawUrl).hostname;
|
|
110
|
+
} catch {
|
|
111
|
+
return PROXY_HOST;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pickHeader(
|
|
116
|
+
headers: Record<string, string | string[] | undefined>,
|
|
117
|
+
name: string,
|
|
118
|
+
): string | undefined {
|
|
119
|
+
const want = name.toLowerCase();
|
|
120
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
121
|
+
if (k.toLowerCase() !== want) continue;
|
|
122
|
+
if (v == null) continue;
|
|
123
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
124
|
+
if (typeof s === "string" && s.length > 0) {
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
package/index.js
CHANGED
|
@@ -21,55 +21,91 @@ function resolvePublishDir(constants, netlifyConfig) {
|
|
|
21
21
|
return path.isAbsolute(p) ? p : path.join(process.cwd(), p);
|
|
22
22
|
}
|
|
23
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
|
+
|
|
24
39
|
module.exports = {
|
|
25
40
|
async onPreBuild({ netlifyConfig, inputs }) {
|
|
26
41
|
const apiUrl =
|
|
27
42
|
inputs.api_url ?? process.env.QELOS_API_IP ?? DEFAULT_API_URL;
|
|
28
43
|
const bypassAdmin =
|
|
29
44
|
inputs.bypass_admin === true || inputs.bypass_admin === 'true';
|
|
45
|
+
const useFunctionProxy =
|
|
46
|
+
inputs.use_function_proxy === true || inputs.use_function_proxy === 'true';
|
|
30
47
|
|
|
31
|
-
// Ensure build.environment exists
|
|
32
48
|
if (!netlifyConfig.build.environment) {
|
|
33
49
|
netlifyConfig.build.environment = {};
|
|
34
50
|
}
|
|
35
51
|
netlifyConfig.build.environment.QELOS_API_IP = apiUrl;
|
|
52
|
+
netlifyConfig.build.environment.QELOS_NETLIFY_PROXY_MODE = useFunctionProxy
|
|
53
|
+
? 'function'
|
|
54
|
+
: 'cdn';
|
|
36
55
|
if (bypassAdmin) {
|
|
37
56
|
netlifyConfig.build.environment.QELOS_BYPASS_ADMIN_HEADER = 'true';
|
|
38
57
|
}
|
|
39
58
|
|
|
40
|
-
if (!netlifyConfig.
|
|
41
|
-
netlifyConfig.
|
|
59
|
+
if (!netlifyConfig.redirects) {
|
|
60
|
+
netlifyConfig.redirects = [];
|
|
42
61
|
}
|
|
62
|
+
netlifyConfig.redirects = stripApiRedirects(netlifyConfig.redirects);
|
|
43
63
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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);
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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);
|
|
55
92
|
}
|
|
56
|
-
netlifyConfig.redirects.push({
|
|
57
|
-
from: '/api/*',
|
|
58
|
-
to: '/.netlify/functions/qelos-api-proxy',
|
|
59
|
-
status: 200,
|
|
60
|
-
force: true,
|
|
61
|
-
});
|
|
62
93
|
},
|
|
63
94
|
|
|
64
|
-
|
|
65
|
-
* Best-effort: some Netlify builds omit logging or skip this hook; the supported fix
|
|
66
|
-
* for Nuxt is `postbuild` → `qelos-netlify-patch-redirects` (see README).
|
|
67
|
-
*/
|
|
68
|
-
async onPostBuild({ constants, netlifyConfig }) {
|
|
95
|
+
async onPostBuild({ constants, netlifyConfig, inputs }) {
|
|
69
96
|
const publishDir = resolvePublishDir(constants, netlifyConfig);
|
|
70
|
-
const
|
|
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);
|
|
71
107
|
console.log(
|
|
72
|
-
`[@qelos/plugin-netlify-api] onPostBuild: ${changed ? 'patched' : '
|
|
108
|
+
`[@qelos/plugin-netlify-api] onPostBuild: ${changed ? 'patched' : 'unchanged'} ${path.join(publishDir, '_redirects')} (${mode})`,
|
|
73
109
|
);
|
|
74
110
|
},
|
|
75
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
|
@@ -2,24 +2,66 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Prepend `/api/*`
|
|
6
|
-
* Netlify evaluates
|
|
7
|
-
* `/* → /404.html`
|
|
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
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
11
|
*/
|
|
12
12
|
|
|
13
13
|
const fs = require('node:fs');
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
|
|
16
|
-
const
|
|
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
|
+
}
|
|
17
28
|
|
|
18
29
|
/**
|
|
19
|
-
* @param {string}
|
|
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]
|
|
20
62
|
* @returns {boolean} true if the file was written
|
|
21
63
|
*/
|
|
22
|
-
function patchRedirects(publishDir) {
|
|
64
|
+
function patchRedirects(publishDir, apiUrl, mode) {
|
|
23
65
|
if (!publishDir) return false;
|
|
24
66
|
const redirectsPath = path.join(publishDir, '_redirects');
|
|
25
67
|
let existing = '';
|
|
@@ -28,10 +70,13 @@ function patchRedirects(publishDir) {
|
|
|
28
70
|
} catch (e) {
|
|
29
71
|
if (e.code !== 'ENOENT') throw e;
|
|
30
72
|
}
|
|
31
|
-
|
|
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) {
|
|
32
77
|
return false;
|
|
33
78
|
}
|
|
34
|
-
fs.writeFileSync(redirectsPath,
|
|
79
|
+
fs.writeFileSync(redirectsPath, next, 'utf8');
|
|
35
80
|
return true;
|
|
36
81
|
}
|
|
37
82
|
|
|
@@ -42,15 +87,24 @@ function resolvePublishDirFromEnv() {
|
|
|
42
87
|
|
|
43
88
|
if (require.main === module) {
|
|
44
89
|
const abs = resolvePublishDirFromEnv();
|
|
45
|
-
const
|
|
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);
|
|
46
94
|
const target = path.join(abs, '_redirects');
|
|
95
|
+
const kind = mode === 'function' ? 'function' : 'CDN';
|
|
47
96
|
if (changed) {
|
|
48
|
-
console.log(`[qelos-netlify-patch-redirects] prepended /api/* rewrite → ${target}`);
|
|
49
|
-
} else {
|
|
50
97
|
console.log(
|
|
51
|
-
`[qelos-netlify-patch-redirects]
|
|
98
|
+
`[qelos-netlify-patch-redirects] prepended /api/* (${kind}) → ${target}`,
|
|
52
99
|
);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`[qelos-netlify-patch-redirects] up to date → ${target}`);
|
|
53
102
|
}
|
|
54
103
|
}
|
|
55
104
|
|
|
56
|
-
module.exports = {
|
|
105
|
+
module.exports = {
|
|
106
|
+
patchRedirects,
|
|
107
|
+
buildRedirectLine,
|
|
108
|
+
upstreamOrigin,
|
|
109
|
+
stripApiRedirectLines,
|
|
110
|
+
};
|