@tabbr/module-cli 0.1.0 → 0.2.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/LICENSE +34 -34
- package/README.md +193 -113
- package/bin/tabbr-module.js +40 -5
- package/package.json +34 -33
- package/src/auth.js +144 -144
- package/src/backend-build.js +74 -0
- package/src/build.js +121 -91
- package/src/config.js +75 -75
- package/src/fetch-types.js +49 -49
- package/src/local.js +62 -0
- package/src/publish.js +31 -29
package/src/auth.js
CHANGED
|
@@ -1,144 +1,144 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const http = require('http');
|
|
6
|
-
const os = require('os');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
|
|
9
|
-
// Same Keycloak client the tabbr-ui shell and the SDK's dev bridge use - see
|
|
10
|
-
// tabbr-ui/src/environments/environment.ts and tabbr-module-sdk/src/dev-bridge.
|
|
11
|
-
const KEYCLOAK_URL = 'https://auth.tabbr.net';
|
|
12
|
-
const KEYCLOAK_REALM = 'tabbr';
|
|
13
|
-
const KEYCLOAK_CLIENT_ID = 'tabbr';
|
|
14
|
-
|
|
15
|
-
const CREDENTIALS_PATH = path.join(os.homedir(), '.tabbr', 'credentials.json');
|
|
16
|
-
|
|
17
|
-
function base64url(buffer) {
|
|
18
|
-
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function readCachedCredentials() {
|
|
22
|
-
try {
|
|
23
|
-
return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
24
|
-
} catch {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function writeCachedCredentials(creds) {
|
|
30
|
-
fs.mkdirSync(path.dirname(CREDENTIALS_PATH), { recursive: true });
|
|
31
|
-
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function exchangeToken(params) {
|
|
35
|
-
const response = await fetch(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, {
|
|
36
|
-
method: 'POST',
|
|
37
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
38
|
-
body: new URLSearchParams(params).toString(),
|
|
39
|
-
});
|
|
40
|
-
if (!response.ok) {
|
|
41
|
-
throw new Error(`Token request failed: ${response.status} ${await response.text()}`);
|
|
42
|
-
}
|
|
43
|
-
return response.json();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function toCachedCredentials(tokenResponse) {
|
|
47
|
-
return {
|
|
48
|
-
accessToken: tokenResponse.access_token,
|
|
49
|
-
refreshToken: tokenResponse.refresh_token,
|
|
50
|
-
// A little slack so a token that's about to expire doesn't get used anyway.
|
|
51
|
-
expiresAt: Date.now() + (tokenResponse.expires_in - 30) * 1000,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function refreshAccessToken(cached) {
|
|
56
|
-
const tokenResponse = await exchangeToken({
|
|
57
|
-
grant_type: 'refresh_token',
|
|
58
|
-
refresh_token: cached.refreshToken,
|
|
59
|
-
client_id: KEYCLOAK_CLIENT_ID,
|
|
60
|
-
});
|
|
61
|
-
return toCachedCredentials(tokenResponse);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Full-page-redirect + PKCE login via a temporary local HTTP server catching the callback. */
|
|
65
|
-
async function interactiveLogin() {
|
|
66
|
-
const verifier = base64url(crypto.randomBytes(32));
|
|
67
|
-
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
68
|
-
|
|
69
|
-
// Set once the server actually has a port (inside listen's callback) and reused below -
|
|
70
|
-
// server.address() returns null once server.close() has been called, so the request handler
|
|
71
|
-
// must not try to recompute it from the (by-then-closing) server itself.
|
|
72
|
-
let redirectUri;
|
|
73
|
-
|
|
74
|
-
const { code } = await new Promise((resolve, reject) => {
|
|
75
|
-
const server = http.createServer((req, res) => {
|
|
76
|
-
const url = new URL(req.url, 'http://localhost');
|
|
77
|
-
if (url.pathname !== '/callback') {
|
|
78
|
-
res.writeHead(404).end();
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const code = url.searchParams.get('code');
|
|
82
|
-
const error = url.searchParams.get('error');
|
|
83
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
84
|
-
res.end(error
|
|
85
|
-
? `<h1>Login failed</h1><p>${error}</p><p>You can close this tab.</p>`
|
|
86
|
-
: '<h1>Logged in</h1><p>You can close this tab and return to the terminal.</p>');
|
|
87
|
-
server.close();
|
|
88
|
-
if (error) {
|
|
89
|
-
reject(new Error(`Keycloak login failed: ${error}`));
|
|
90
|
-
} else {
|
|
91
|
-
resolve({ code });
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
server.listen(0, 'localhost', () => {
|
|
95
|
-
const port = server.address().port;
|
|
96
|
-
redirectUri = `http://localhost:${port}/callback`;
|
|
97
|
-
const authUrl = new URL(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`);
|
|
98
|
-
authUrl.searchParams.set('client_id', KEYCLOAK_CLIENT_ID);
|
|
99
|
-
authUrl.searchParams.set('response_type', 'code');
|
|
100
|
-
authUrl.searchParams.set('scope', 'openid');
|
|
101
|
-
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
102
|
-
authUrl.searchParams.set('code_challenge', challenge);
|
|
103
|
-
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
104
|
-
|
|
105
|
-
console.log('Opening your browser to log in via Keycloak...');
|
|
106
|
-
console.log(`If it doesn't open automatically, visit:\n${authUrl.toString()}\n`);
|
|
107
|
-
import('open').then(({ default: open }) => open(authUrl.toString())).catch(() => {
|
|
108
|
-
// Non-fatal: the URL is already printed above for manual use.
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
server.on('error', reject);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
const tokenResponse = await exchangeToken({
|
|
115
|
-
grant_type: 'authorization_code',
|
|
116
|
-
code,
|
|
117
|
-
redirect_uri: redirectUri,
|
|
118
|
-
client_id: KEYCLOAK_CLIENT_ID,
|
|
119
|
-
code_verifier: verifier,
|
|
120
|
-
});
|
|
121
|
-
return toCachedCredentials(tokenResponse);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Returns a valid access token, reusing/refreshing the cache before falling back to a fresh login. */
|
|
125
|
-
async function getAccessToken() {
|
|
126
|
-
const cached = readCachedCredentials();
|
|
127
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
128
|
-
return cached.accessToken;
|
|
129
|
-
}
|
|
130
|
-
if (cached && cached.refreshToken) {
|
|
131
|
-
try {
|
|
132
|
-
const refreshed = await refreshAccessToken(cached);
|
|
133
|
-
writeCachedCredentials(refreshed);
|
|
134
|
-
return refreshed.accessToken;
|
|
135
|
-
} catch {
|
|
136
|
-
// Refresh token expired/revoked - fall through to interactive login.
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
const creds = await interactiveLogin();
|
|
140
|
-
writeCachedCredentials(creds);
|
|
141
|
-
return creds.accessToken;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
module.exports = { getAccessToken, CREDENTIALS_PATH };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
// Same Keycloak client the tabbr-ui shell and the SDK's dev bridge use - see
|
|
10
|
+
// tabbr-ui/src/environments/environment.ts and tabbr-module-sdk/src/dev-bridge.
|
|
11
|
+
const KEYCLOAK_URL = 'https://auth.tabbr.net';
|
|
12
|
+
const KEYCLOAK_REALM = 'tabbr';
|
|
13
|
+
const KEYCLOAK_CLIENT_ID = 'tabbr';
|
|
14
|
+
|
|
15
|
+
const CREDENTIALS_PATH = path.join(os.homedir(), '.tabbr', 'credentials.json');
|
|
16
|
+
|
|
17
|
+
function base64url(buffer) {
|
|
18
|
+
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readCachedCredentials() {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeCachedCredentials(creds) {
|
|
30
|
+
fs.mkdirSync(path.dirname(CREDENTIALS_PATH), { recursive: true });
|
|
31
|
+
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function exchangeToken(params) {
|
|
35
|
+
const response = await fetch(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
38
|
+
body: new URLSearchParams(params).toString(),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new Error(`Token request failed: ${response.status} ${await response.text()}`);
|
|
42
|
+
}
|
|
43
|
+
return response.json();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toCachedCredentials(tokenResponse) {
|
|
47
|
+
return {
|
|
48
|
+
accessToken: tokenResponse.access_token,
|
|
49
|
+
refreshToken: tokenResponse.refresh_token,
|
|
50
|
+
// A little slack so a token that's about to expire doesn't get used anyway.
|
|
51
|
+
expiresAt: Date.now() + (tokenResponse.expires_in - 30) * 1000,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function refreshAccessToken(cached) {
|
|
56
|
+
const tokenResponse = await exchangeToken({
|
|
57
|
+
grant_type: 'refresh_token',
|
|
58
|
+
refresh_token: cached.refreshToken,
|
|
59
|
+
client_id: KEYCLOAK_CLIENT_ID,
|
|
60
|
+
});
|
|
61
|
+
return toCachedCredentials(tokenResponse);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Full-page-redirect + PKCE login via a temporary local HTTP server catching the callback. */
|
|
65
|
+
async function interactiveLogin() {
|
|
66
|
+
const verifier = base64url(crypto.randomBytes(32));
|
|
67
|
+
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
|
68
|
+
|
|
69
|
+
// Set once the server actually has a port (inside listen's callback) and reused below -
|
|
70
|
+
// server.address() returns null once server.close() has been called, so the request handler
|
|
71
|
+
// must not try to recompute it from the (by-then-closing) server itself.
|
|
72
|
+
let redirectUri;
|
|
73
|
+
|
|
74
|
+
const { code } = await new Promise((resolve, reject) => {
|
|
75
|
+
const server = http.createServer((req, res) => {
|
|
76
|
+
const url = new URL(req.url, 'http://localhost');
|
|
77
|
+
if (url.pathname !== '/callback') {
|
|
78
|
+
res.writeHead(404).end();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const code = url.searchParams.get('code');
|
|
82
|
+
const error = url.searchParams.get('error');
|
|
83
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
84
|
+
res.end(error
|
|
85
|
+
? `<h1>Login failed</h1><p>${error}</p><p>You can close this tab.</p>`
|
|
86
|
+
: '<h1>Logged in</h1><p>You can close this tab and return to the terminal.</p>');
|
|
87
|
+
server.close();
|
|
88
|
+
if (error) {
|
|
89
|
+
reject(new Error(`Keycloak login failed: ${error}`));
|
|
90
|
+
} else {
|
|
91
|
+
resolve({ code });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
server.listen(0, 'localhost', () => {
|
|
95
|
+
const port = server.address().port;
|
|
96
|
+
redirectUri = `http://localhost:${port}/callback`;
|
|
97
|
+
const authUrl = new URL(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`);
|
|
98
|
+
authUrl.searchParams.set('client_id', KEYCLOAK_CLIENT_ID);
|
|
99
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
100
|
+
authUrl.searchParams.set('scope', 'openid');
|
|
101
|
+
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
102
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
103
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
104
|
+
|
|
105
|
+
console.log('Opening your browser to log in via Keycloak...');
|
|
106
|
+
console.log(`If it doesn't open automatically, visit:\n${authUrl.toString()}\n`);
|
|
107
|
+
import('open').then(({ default: open }) => open(authUrl.toString())).catch(() => {
|
|
108
|
+
// Non-fatal: the URL is already printed above for manual use.
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
server.on('error', reject);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const tokenResponse = await exchangeToken({
|
|
115
|
+
grant_type: 'authorization_code',
|
|
116
|
+
code,
|
|
117
|
+
redirect_uri: redirectUri,
|
|
118
|
+
client_id: KEYCLOAK_CLIENT_ID,
|
|
119
|
+
code_verifier: verifier,
|
|
120
|
+
});
|
|
121
|
+
return toCachedCredentials(tokenResponse);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Returns a valid access token, reusing/refreshing the cache before falling back to a fresh login. */
|
|
125
|
+
async function getAccessToken() {
|
|
126
|
+
const cached = readCachedCredentials();
|
|
127
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
128
|
+
return cached.accessToken;
|
|
129
|
+
}
|
|
130
|
+
if (cached && cached.refreshToken) {
|
|
131
|
+
try {
|
|
132
|
+
const refreshed = await refreshAccessToken(cached);
|
|
133
|
+
writeCachedCredentials(refreshed);
|
|
134
|
+
return refreshed.accessToken;
|
|
135
|
+
} catch {
|
|
136
|
+
// Refresh token expired/revoked - fall through to interactive login.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const creds = await interactiveLogin();
|
|
140
|
+
writeCachedCredentials(creds);
|
|
141
|
+
return creds.accessToken;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { getAccessToken, CREDENTIALS_PATH };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_ENTRY = 'backend/src/index.ts';
|
|
7
|
+
|
|
8
|
+
/** Matches BackendModuleRuntimeService's MODULE_GLOBAL on the server - a `export function foo() {}`
|
|
9
|
+
* in the module's TS ends up reachable as the global `__module.foo` once bundled this way. */
|
|
10
|
+
const GLOBAL_NAME = '__module';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bundles a module workspace's backend part (if configured) into a single, dependency-free
|
|
14
|
+
* `bundle.js`, using esbuild so module authors can write normal TypeScript (types, imports,
|
|
15
|
+
* `export function`) even though nothing beyond that single file is ever loaded at runtime - the
|
|
16
|
+
* server evaluates it in a sandboxed GraalVM context with no module resolution of its own (see
|
|
17
|
+
* `BackendModuleRuntimeService`).
|
|
18
|
+
*
|
|
19
|
+
* Returns `null` when the workspace has no backend part configured, so `build.js` can skip it
|
|
20
|
+
* entirely for a frontend-only module.
|
|
21
|
+
*/
|
|
22
|
+
async function buildBackend(cwd, config) {
|
|
23
|
+
const backendConfig = config.backend;
|
|
24
|
+
if (!backendConfig) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const entry = backendConfig.entry || DEFAULT_ENTRY;
|
|
28
|
+
const entryPath = path.join(cwd, entry);
|
|
29
|
+
if (!fs.existsSync(entryPath)) {
|
|
30
|
+
if (backendConfig.entry) {
|
|
31
|
+
// Explicitly configured but missing - fail loudly rather than silently skipping.
|
|
32
|
+
throw new Error(`Backend entry ${entryPath} does not exist (check "backend.entry" in tabbr.config.json).`);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let esbuild;
|
|
38
|
+
try {
|
|
39
|
+
// Resolved from the workspace being built, not from wherever this CLI happens to be installed -
|
|
40
|
+
// esbuild is the module author's devDependency, and the CLI may be hoisted, copied or linked.
|
|
41
|
+
esbuild = require(require.resolve('esbuild', { paths: [cwd] }));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'esbuild is required to build this module\'s backend part. Run "npm install --save-dev esbuild" in your module workspace.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(`Building backend module ${entryPath} ...`);
|
|
49
|
+
const result = await esbuild.build({
|
|
50
|
+
entryPoints: [entryPath],
|
|
51
|
+
bundle: true,
|
|
52
|
+
write: false,
|
|
53
|
+
format: 'iife',
|
|
54
|
+
globalName: GLOBAL_NAME,
|
|
55
|
+
target: 'es2022',
|
|
56
|
+
platform: 'neutral',
|
|
57
|
+
logLevel: 'silent',
|
|
58
|
+
});
|
|
59
|
+
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
60
|
+
throw new Error(`esbuild produced no output for ${entryPath}`);
|
|
61
|
+
}
|
|
62
|
+
const bundleJs = result.outputFiles[0].text;
|
|
63
|
+
|
|
64
|
+
const manifest = {
|
|
65
|
+
// localAllowedHosts (e.g. a mock on localhost) only ever go into a --local publish, never into one for a real environment.
|
|
66
|
+
allowedHosts: [...(backendConfig.allowedHosts || []), ...(config.local ? backendConfig.localAllowedHosts || [] : [])],
|
|
67
|
+
secretFieldNames: backendConfig.secretFieldNames || [],
|
|
68
|
+
triggers: backendConfig.triggers || [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return { bundleJs, manifestJson: JSON.stringify(manifest, null, 2) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { buildBackend, DEFAULT_ENTRY };
|
package/src/build.js
CHANGED
|
@@ -1,91 +1,121 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { execFileSync } = require('child_process');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const archiver = require('archiver');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
archive
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const archiver = require('archiver');
|
|
7
|
+
const { buildBackend } = require('./backend-build');
|
|
8
|
+
|
|
9
|
+
/** Reads angular.json and resolves the browser output directory for the workspace's one project. */
|
|
10
|
+
function resolveOutputDir(cwd) {
|
|
11
|
+
const angularJsonPath = path.join(cwd, 'angular.json');
|
|
12
|
+
if (!fs.existsSync(angularJsonPath)) {
|
|
13
|
+
throw new Error(`No angular.json found in ${cwd} - run this from an Angular workspace root (or pass --cwd).`);
|
|
14
|
+
}
|
|
15
|
+
const angularJson = JSON.parse(fs.readFileSync(angularJsonPath, 'utf-8'));
|
|
16
|
+
const projectName = Object.keys(angularJson.projects)[0];
|
|
17
|
+
const project = angularJson.projects[projectName];
|
|
18
|
+
// native-federation's build target wraps the real Angular application builder under
|
|
19
|
+
// "build-original" (see tabbr-ui/angular.json and tabbr-custom-module-poc/angular.json) -
|
|
20
|
+
// that's where the actual outputPath lives.
|
|
21
|
+
const buildTarget = project.architect['build-original'] || project.architect['build'];
|
|
22
|
+
const outputPath = buildTarget.options.outputPath;
|
|
23
|
+
const base = typeof outputPath === 'string' ? outputPath : outputPath.base;
|
|
24
|
+
// Angular's application builder always nests the actual browser bundle under a "browser/"
|
|
25
|
+
// subfolder of outputPath - confirmed empirically against this exact builder this session.
|
|
26
|
+
return path.join(cwd, base, 'browser');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs `ng build` in `cwd` by invoking the workspace's own locally-installed Angular CLI entry
|
|
31
|
+
* point directly with the current Node binary.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately NOT `execFileSync('npx', ['ng', 'build'], { shell: true })`, which was the previous
|
|
34
|
+
* implementation and had three problems at once:
|
|
35
|
+
* - Node's DEP0190 deprecation warning (passing an args array together with `shell: true`).
|
|
36
|
+
* - Passing args through a shell at all means they're re-parsed by the shell, so anything less
|
|
37
|
+
* trusted than a hardcoded literal would need manual escaping.
|
|
38
|
+
* - On Windows `npx` is `npx.cmd`, and since the CVE-2024-27980 fix Node refuses to spawn
|
|
39
|
+
* `.cmd`/`.bat` WITHOUT `shell: true` - so dropping the shell alone wasn't an option there.
|
|
40
|
+
* Going straight to `node node_modules/@angular/cli/bin/ng.js` sidesteps all three: no shell, no
|
|
41
|
+
* args re-parsing, no platform-specific launcher script, and it's faster (skips npx resolution).
|
|
42
|
+
*
|
|
43
|
+
* Explicitly `--configuration production`, not a bare `ng build`: a module workspace's
|
|
44
|
+
* angular.json typically sets `defaultConfiguration: "development"` on the native-federation
|
|
45
|
+
* build target (so a plain `ng build`/`ng serve` during local development doesn't need the flag
|
|
46
|
+
* spelled out every time) - which a bare `ng build` here would silently inherit, publishing an
|
|
47
|
+
* unoptimized dev build (unminified, `dev: true`, native-federation's build-notifications
|
|
48
|
+
* live-reload hook baked in) to the club instead of the production one. The live-reload hook in
|
|
49
|
+
* particular then 404s at runtime (it points at a dev server that doesn't exist once statically
|
|
50
|
+
* published) and the unshared-per-pass token identities a dev build produces differently from
|
|
51
|
+
* prod have caused real `NG0201: No provider found for ...` failures in published modules.
|
|
52
|
+
*/
|
|
53
|
+
function runNgBuild(cwd) {
|
|
54
|
+
const ngJs = path.join(cwd, 'node_modules', '@angular', 'cli', 'bin', 'ng.js');
|
|
55
|
+
if (!fs.existsSync(ngJs)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`No local Angular CLI found at ${ngJs} - run "npm install" in ${cwd} first.`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
console.log(`Building in ${cwd} ...`);
|
|
61
|
+
execFileSync(process.execPath, [ngJs, 'build', '--configuration', 'production'], { cwd, stdio: 'inherit' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Zips frontendDir's contents flat at the zip root (remoteEntry.json at the root, as before) plus,
|
|
66
|
+
* when present, the backend bundle/manifest under a `backend/` prefix - one zip, one upload, no
|
|
67
|
+
* separate publish step per side (see `CustomModuleService#publish` on the server, which splits
|
|
68
|
+
* them back apart by that same prefix).
|
|
69
|
+
*/
|
|
70
|
+
function zipModule(frontendDir, backend) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
73
|
+
const chunks = [];
|
|
74
|
+
archive.on('data', chunk => chunks.push(chunk));
|
|
75
|
+
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
76
|
+
archive.on('error', reject);
|
|
77
|
+
if (frontendDir) {
|
|
78
|
+
archive.directory(frontendDir, false);
|
|
79
|
+
}
|
|
80
|
+
if (backend) {
|
|
81
|
+
archive.append(backend.bundleJs, { name: 'backend/bundle.js' });
|
|
82
|
+
archive.append(backend.manifestJson, { name: 'backend/manifest.json' });
|
|
83
|
+
}
|
|
84
|
+
archive.finalize();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds whichever parts of the workspace at `cwd` are present - a frontend (an Angular project,
|
|
90
|
+
* i.e. angular.json exists) and/or a backend (`config.backend` set, see `backend-build.js`) - and
|
|
91
|
+
* returns a single zip Buffer with both. At least one part must be present.
|
|
92
|
+
*/
|
|
93
|
+
async function buildAndZip(cwd, config) {
|
|
94
|
+
const hasFrontend = fs.existsSync(path.join(cwd, 'angular.json'));
|
|
95
|
+
let frontendDir = null;
|
|
96
|
+
if (hasFrontend) {
|
|
97
|
+
runNgBuild(cwd);
|
|
98
|
+
frontendDir = resolveOutputDir(cwd);
|
|
99
|
+
const remoteEntryPath = path.join(frontendDir, 'remoteEntry.json');
|
|
100
|
+
if (!fs.existsSync(remoteEntryPath)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Build output at ${frontendDir} has no remoteEntry.json. ` +
|
|
103
|
+
`Is this workspace's federation.config.js missing an "exposes" entry?`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const backend = await buildBackend(cwd, config || {});
|
|
109
|
+
|
|
110
|
+
if (!hasFrontend && !backend) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Nothing to build in ${cwd}: no angular.json (frontend) and no backend part configured ` +
|
|
113
|
+
`("backend" in tabbr.config.json, entry file missing).`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log('Zipping module ...');
|
|
118
|
+
return zipModule(frontendDir, backend);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { buildAndZip, resolveOutputDir };
|