@codemoreira/esad 1.0.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/bin/esad.js +233 -0
- package/package.json +30 -0
- package/src/client/index.js +69 -0
- package/src/plugin/index.js +41 -0
package/bin/esad.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const fs = require('fs-extra');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawn } = require('cross-spawn');
|
|
7
|
+
const AdmZip = require('adm-zip');
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.version('1.0.0')
|
|
11
|
+
.description('esad - Easy Super App Development Toolkit');
|
|
12
|
+
|
|
13
|
+
// Helper to spawn commands synchronously
|
|
14
|
+
const runProcess = (cmd, args, cwd = process.cwd()) => {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(cmd, args, { stdio: 'inherit', cwd, shell: true });
|
|
17
|
+
child.on('close', code => {
|
|
18
|
+
if (code !== 0) reject(new Error(`Command ${cmd} ${args.join(' ')} failed`));
|
|
19
|
+
else resolve();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const getWorkspaceConfig = () => {
|
|
25
|
+
let configPath = path.join(process.cwd(), 'esad.config.json');
|
|
26
|
+
if (!fs.existsSync(configPath)) configPath = path.join(process.cwd(), '../esad.config.json');
|
|
27
|
+
if (!fs.existsSync(configPath)) return null;
|
|
28
|
+
return { path: configPath, data: fs.readJsonSync(configPath) };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// --- COMMMAND: esad init ---
|
|
32
|
+
program
|
|
33
|
+
.command('init <project-name>')
|
|
34
|
+
.description('Scaffold a new ESAD workspace containing the Host App')
|
|
35
|
+
.action(async (projectName) => {
|
|
36
|
+
const workspaceDir = path.join(process.cwd(), projectName);
|
|
37
|
+
console.log(`\nš Initializing ESAD Workspace: ${projectName}...\n`);
|
|
38
|
+
|
|
39
|
+
// Create Workspace Dir
|
|
40
|
+
fs.ensureDirSync(workspaceDir);
|
|
41
|
+
|
|
42
|
+
// Create base esad.config.json at root workspace
|
|
43
|
+
const configPath = path.join(workspaceDir, 'esad.config.json');
|
|
44
|
+
if (!fs.existsSync(configPath)) {
|
|
45
|
+
const configTemplate = {
|
|
46
|
+
projectName: projectName,
|
|
47
|
+
registryUrl: "http://localhost:3000/modules",
|
|
48
|
+
deployEndpoint: "http://localhost:3000/api/admin/modules/{{moduleId}}/versions",
|
|
49
|
+
devModeEndpoint: "http://localhost:3000/api/admin/modules/{{moduleId}}"
|
|
50
|
+
};
|
|
51
|
+
fs.writeJsonSync(configPath, configTemplate, { spaces: 2 });
|
|
52
|
+
console.log(`ā
Generated dynamic configuration file: esad.config.json`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Create .gitignore to allow only host and config (Poly-repo support)
|
|
56
|
+
const gitignorePath = path.join(workspaceDir, '.gitignore');
|
|
57
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
58
|
+
const hostName = `${projectName}-host`;
|
|
59
|
+
const gitignoreContent = `# ESAD Workspace Git Configuration\n` +
|
|
60
|
+
`# Ignore everything by default\n` +
|
|
61
|
+
`/*\n\n` +
|
|
62
|
+
`# Exceptions: Track only the Host and Configs\n` +
|
|
63
|
+
`!/${hostName}/\n` +
|
|
64
|
+
`!/esad.config.json\n` +
|
|
65
|
+
`!/.gitignore\n` +
|
|
66
|
+
`\n# Ignore node_modules\n` +
|
|
67
|
+
`node_modules/\n`;
|
|
68
|
+
fs.writeFileSync(gitignorePath, gitignoreContent);
|
|
69
|
+
console.log(`ā
Generated .gitignore (Whitelist mode: ignored modules so they can have their own repos)`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Scaffold Expo app using create-expo-app
|
|
73
|
+
const hostName = `${projectName}-host`;
|
|
74
|
+
const hostDir = path.join(workspaceDir, hostName);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
console.log(`\nš¦ Scaffolding clean Expo project: ${hostName}...`);
|
|
78
|
+
await runProcess('npx', ['create-expo-app', hostName, '--template', 'blank'], workspaceDir);
|
|
79
|
+
|
|
80
|
+
console.log(`\nš¦ Installing ESAD dependencies into host...`);
|
|
81
|
+
// Simulate local ESAD package link (in reality it would be an npm publish fetch)
|
|
82
|
+
await runProcess('npm', ['install', '../../esad'], hostDir);
|
|
83
|
+
|
|
84
|
+
// Generate the Host's rspack config using withESAD
|
|
85
|
+
const rspackContent = `import { withESAD } from 'esad/plugin';\n\nexport default withESAD({\n type: 'host',\n id: '${hostName}'\n});\n`;
|
|
86
|
+
fs.writeFileSync(path.join(hostDir, 'rspack.config.mjs'), rspackContent);
|
|
87
|
+
console.log(`ā
Injected withESAD wrapper into rspack.config.mjs`);
|
|
88
|
+
|
|
89
|
+
console.log(`\nš ESAD Workpace Initialized!`);
|
|
90
|
+
console.log(`-> cd ${projectName}\n-> esad create-cdn\n-> esad create-module modulo1`);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(`ā Failed to init Host:`, err.message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// --- COMMAND: esad create-cdn ---
|
|
97
|
+
program
|
|
98
|
+
.command('create-cdn [cdn-name]')
|
|
99
|
+
.description('Scaffold the CDN / Registry backend')
|
|
100
|
+
.action(async (cdnName) => {
|
|
101
|
+
const configObj = getWorkspaceConfig();
|
|
102
|
+
if (!configObj) {
|
|
103
|
+
console.error(`ā Error: Call this command from inside an ESAD workspace (esad.config.json not found).`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const finalCdnName = cdnName || `${configObj.data.projectName}-cdn`;
|
|
107
|
+
console.log(`\nš¦ Creating CDN Registry: ${finalCdnName}...\n`);
|
|
108
|
+
// Placeholder for backend cloning
|
|
109
|
+
console.log(`[TODO] Scaffold Node Express backend into ./${finalCdnName}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// --- COMMAND: esad create-module ---
|
|
113
|
+
program
|
|
114
|
+
.command('create-module <module-name>')
|
|
115
|
+
.description('Scaffold a React Native mini-app automatically configured for Module Federation via ESAD')
|
|
116
|
+
.action(async (moduleName) => {
|
|
117
|
+
const configObj = getWorkspaceConfig();
|
|
118
|
+
if (!configObj) {
|
|
119
|
+
console.error(`ā Error: Call this command from inside an ESAD workspace (esad.config.json not found).`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { projectName } = configObj.data;
|
|
124
|
+
const isPrefixed = moduleName.startsWith(`${projectName}-`);
|
|
125
|
+
const finalModuleName = isPrefixed ? moduleName : `${projectName}-${moduleName}`;
|
|
126
|
+
|
|
127
|
+
const workspaceDir = path.dirname(configObj.path);
|
|
128
|
+
const targetDir = path.join(workspaceDir, finalModuleName);
|
|
129
|
+
|
|
130
|
+
console.log(`\nš¦ Creating federated mini-app: ${finalModuleName}...\n`);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await runProcess('npx', ['react-native@latest', 'init', finalModuleName], workspaceDir);
|
|
134
|
+
console.log(`\nš¦ Installing ESAD dependencies...`);
|
|
135
|
+
// Simulate local link
|
|
136
|
+
await runProcess('npm', ['install', '../../esad'], targetDir);
|
|
137
|
+
|
|
138
|
+
const rspackContent = `import { withESAD } from 'esad/plugin';\n\nexport default withESAD({\n type: 'module',\n id: '${finalModuleName}'\n});\n`;
|
|
139
|
+
fs.writeFileSync(path.join(targetDir, 'rspack.config.mjs'), rspackContent);
|
|
140
|
+
console.log(`ā
Injected withESAD wrapper into rspack.config.mjs`);
|
|
141
|
+
|
|
142
|
+
console.log(`\nš Module ${finalModuleName} is ready!`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`ā Failed to scaffold module`, err.message);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// --- COMMAND: esad deploy ---
|
|
149
|
+
program
|
|
150
|
+
.command('deploy')
|
|
151
|
+
.requiredOption('-v, --version <semver>', 'Version number (e.g., 1.0.0)')
|
|
152
|
+
.requiredOption('-i, --id <moduleId>', 'The Module ID to deploy')
|
|
153
|
+
.requiredOption('-e, --entry <entryFileName>', 'The name of the main entry bundle (e.g., index.bundle)')
|
|
154
|
+
.description('Zips the local dist directory and uploads it to the configured deployment endpoint')
|
|
155
|
+
.action(async (options) => {
|
|
156
|
+
console.log(`\nāļø Starting ESAD Deploy for ${options.id} (v${options.version})\n`);
|
|
157
|
+
|
|
158
|
+
const configObj = getWorkspaceConfig();
|
|
159
|
+
if (!configObj) {
|
|
160
|
+
console.error(`ā Error: esad.config.json not found in current directory or parent.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const config = configObj.data;
|
|
165
|
+
if (!config.deployEndpoint) {
|
|
166
|
+
console.error(`ā Error: 'deployEndpoint' not configured in esad.config.json.`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const deployUrl = config.deployEndpoint.replace('{{moduleId}}', options.id);
|
|
171
|
+
console.log(`š” Deployment Endpoint Resolved: ${deployUrl}`);
|
|
172
|
+
|
|
173
|
+
const distPath = path.join(process.cwd(), 'dist');
|
|
174
|
+
if (!fs.existsSync(distPath)) {
|
|
175
|
+
console.error(`ā Error: dist/ directory not found. Did you run the build command?`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const zip = new AdmZip();
|
|
180
|
+
zip.addLocalFolder(distPath);
|
|
181
|
+
|
|
182
|
+
const zipPath = path.join(process.cwd(), `bundle-${options.id}-${options.version}.zip`);
|
|
183
|
+
zip.writeZip(zipPath);
|
|
184
|
+
console.log(`šļø Zipped output to ${zipPath}`);
|
|
185
|
+
|
|
186
|
+
console.log(`š Uploading to CDN via multipart POST...`);
|
|
187
|
+
// Example fetch upload would go here
|
|
188
|
+
|
|
189
|
+
console.log(`ā
[SIMULATED] Successfully uploaded to ${deployUrl}`);
|
|
190
|
+
fs.unlinkSync(zipPath);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// --- COMMAND: esad dev ---
|
|
194
|
+
program
|
|
195
|
+
.command('dev')
|
|
196
|
+
.requiredOption('-i, --id <moduleId>', 'The Module ID to run in dev mode')
|
|
197
|
+
.option('-p, --port <port>', 'The port to run the dev server on', '8081')
|
|
198
|
+
.description('Starts the dev server and updates the external registry to bypass CDN')
|
|
199
|
+
.action(async (options) => {
|
|
200
|
+
console.log(`\nā” Starting ESAD Dev Server for ${options.id} on port ${options.port}...\n`);
|
|
201
|
+
|
|
202
|
+
const configObj = getWorkspaceConfig();
|
|
203
|
+
const config = configObj ? configObj.data : null;
|
|
204
|
+
let devApiUrl = config?.devModeEndpoint ? config.devModeEndpoint.replace('{{moduleId}}', options.id) : null;
|
|
205
|
+
|
|
206
|
+
const setDevMode = async (isActive) => {
|
|
207
|
+
if (!devApiUrl) return;
|
|
208
|
+
try {
|
|
209
|
+
const body = {
|
|
210
|
+
is_dev_mode: isActive,
|
|
211
|
+
...(isActive && { dev_url: `http://localhost:${options.port}/index.bundle` })
|
|
212
|
+
};
|
|
213
|
+
const res = await fetch(devApiUrl, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
214
|
+
if (res.ok) console.log(`š” Registry Sync: Dev Override is ${isActive ? 'ON' : 'OFF'} para o modulo Host`);
|
|
215
|
+
} catch(e) { /* ignore */ }
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
await setDevMode(true);
|
|
219
|
+
|
|
220
|
+
const proc = spawn('npx', ['react-native', 'webpack-start', '--port', options.port], { stdio: 'inherit', shell: true });
|
|
221
|
+
|
|
222
|
+
const shutdown = async () => {
|
|
223
|
+
console.log(`\nš Parando ESAD Dev Server e revertendo o registro na CDN...`);
|
|
224
|
+
await setDevMode(false);
|
|
225
|
+
proc.kill();
|
|
226
|
+
process.exit(0);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
process.on('SIGINT', shutdown);
|
|
230
|
+
process.on('SIGTERM', shutdown);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codemoreira/esad",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Easy Super App Development - Zero-Config CLI and DevTools for React Native Module Federation",
|
|
5
|
+
"main": "src/plugin/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/plugin/index.js",
|
|
8
|
+
"./plugin": "./src/plugin/index.js",
|
|
9
|
+
"./client": "./src/client/index.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"esad": "./bin/esad.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"adm-zip": "^0.5.10",
|
|
24
|
+
"chalk": "^4.1.2",
|
|
25
|
+
"commander": "^11.1.0",
|
|
26
|
+
"cross-spawn": "^7.0.3",
|
|
27
|
+
"fs-extra": "^11.2.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {}
|
|
30
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ESAD Global Event Manager
|
|
5
|
+
* This class runs as a true Singleton across the Host and all Federated Modules,
|
|
6
|
+
* allowing instant variable sharing without tight coupling.
|
|
7
|
+
*/
|
|
8
|
+
class ESADEventEmitter {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.state = {};
|
|
11
|
+
this.listeners = {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
set(key, value) {
|
|
15
|
+
this.state[key] = value;
|
|
16
|
+
if (this.listeners[key]) {
|
|
17
|
+
this.listeners[key].forEach(callback => callback(value));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(key) {
|
|
22
|
+
return this.state[key];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
subscribe(key, callback) {
|
|
26
|
+
if (!this.listeners[key]) this.listeners[key] = [];
|
|
27
|
+
this.listeners[key].push(callback);
|
|
28
|
+
return () => {
|
|
29
|
+
this.listeners[key] = this.listeners[key].filter(cb => cb !== callback);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Because this package is marked as a ModuleFederation Singleton,
|
|
35
|
+
// this instance will be shared identically across all chunks!
|
|
36
|
+
const ESADState = new ESADEventEmitter();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* React Hook for subscribing to Global State Changes
|
|
40
|
+
* @param {string} key Unique identifier for the state slice (e.g. 'auth_token', 'theme')
|
|
41
|
+
* @param {any} initialValue Optional initial state fallback
|
|
42
|
+
*/
|
|
43
|
+
export function useESADState(key, initialValue) {
|
|
44
|
+
const [val, setVal] = useState(() => {
|
|
45
|
+
const existing = ESADState.get(key);
|
|
46
|
+
if (existing !== undefined) return existing;
|
|
47
|
+
if (initialValue !== undefined) {
|
|
48
|
+
ESADState.set(key, initialValue);
|
|
49
|
+
return initialValue;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
// Whenever ESADState.set is called matching this key, this component will re-render
|
|
56
|
+
const unsubscribe = ESADState.subscribe(key, (newVal) => {
|
|
57
|
+
setVal(newVal);
|
|
58
|
+
});
|
|
59
|
+
return unsubscribe;
|
|
60
|
+
}, [key]);
|
|
61
|
+
|
|
62
|
+
const setter = (newVal) => {
|
|
63
|
+
ESADState.set(key, newVal);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return [val, setter];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { ESADState };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const Repack = require('@callstack/repack');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ESAD Re.Pack Plugin Wrapper
|
|
5
|
+
* Abstracts away the boilerplate of Module Federation for SuperApps.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @param {string} options.type 'host' | 'module'
|
|
9
|
+
* @param {string} options.id Unique module or host ID
|
|
10
|
+
*/
|
|
11
|
+
function withESAD(options) {
|
|
12
|
+
return (env) => {
|
|
13
|
+
// In a real scenario, we merge heavily with Repack.getTemplateConfig here
|
|
14
|
+
console.log(`[ESAD Plugin] Applying Zero-Config Re.Pack profile for ${options.type.toUpperCase()}: ${options.id}`);
|
|
15
|
+
|
|
16
|
+
// Ensure the esad state library is ALWAYS shared
|
|
17
|
+
const sharedConfig = {
|
|
18
|
+
react: { singleton: true, eager: options.type === 'host' },
|
|
19
|
+
'react-native': { singleton: true, eager: options.type === 'host' },
|
|
20
|
+
'esad/client': { singleton: true, eager: true } // Crucial for Global State
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
// Configuration abstraction
|
|
25
|
+
plugins: [
|
|
26
|
+
new Repack.plugins.ModuleFederationPlugin({
|
|
27
|
+
name: options.id.replace(/-/g, '_'),
|
|
28
|
+
shared: sharedConfig,
|
|
29
|
+
// If type is module, also configure exposes
|
|
30
|
+
...(options.type === 'module' && {
|
|
31
|
+
exposes: {
|
|
32
|
+
'./App': './src/App'
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { withESAD };
|