Agregateur d'API reverse-proxy avec enregistrement et decouverte de
services au runtime, deux points d'entree separes partageant un meme
protocole :
- ./client (DiscoveryClient) : enregistre un service et maintient un
heartbeat periodique (qui sert aussi de mecanisme de retry - un echec
est journalise et laisse au tick suivant, pas de logique separee).
stop() est best-effort et ne rejette jamais.
- ./aggregator (ServiceAggregator) : handlers Node natifs a monter dans
Express/Fastify/http brut (exemples des trois dans le README), zero
dependance framework. Routage par prefixe de nom de service, TTL avec
balayage periodique, secret partage compare en temps constant, proxy
HTTP fait main (node:http/https, streaming, filtrage hop-by-hop,
distinction 502/504).
Limites documentees dans le README plutot que corrigees : pas de
matching :param (v1, match exact), pas de secret par service, risque
SSRF-like assume vu le threat model homelab.
Resolution des sous-chemins via des stubs package.json (client/,
aggregator/) puisque le repo cible moduleResolution: Node10, pas de
champ exports moderne - pattern documente dans spec/architecture.md
pour de futurs packages server/ multi-points d'entree.
Bug trouve et corrige en cours de route : req.on('close') se declenche
des que le corps de la requete entrante est lu (immediat pour un GET
sans body), pas a la deconnexion reelle du client - detruisait ma
propre requete sortante juste apres l'avoir envoyee. Le bon signal est
res.on('close').
Correction connexe : create-package.mjs inserait la nouvelle ligne en
fin de fichier de spec/packages.md au lieu de l'inserer dans le
tableau - corrige pour les deux packages precedents au passage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
5.6 KiB
JavaScript
176 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
const CATEGORIES = ['client', 'common', 'server'];
|
|
|
|
/**
|
|
* Prints usage information and exits with an error status.
|
|
* @param {string} message the reason the script is aborting
|
|
*/
|
|
function fail(message) {
|
|
console.error(`Erreur : ${message}`);
|
|
console.error('Usage : pnpm create-package <client|common|server> <nom-du-package>');
|
|
process.exitCode = 1;
|
|
process.exit(1);
|
|
}
|
|
|
|
const [, , category, name] = process.argv;
|
|
|
|
if (!category || !CATEGORIES.includes(category)) {
|
|
fail(`catégorie invalide (attendu : ${CATEGORIES.join(', ')})`);
|
|
}
|
|
if (!name || !/^[a-z][a-z0-9-]*[a-z0-9]$/.test(name)) {
|
|
fail('nom de package invalide (attendu : kebab-case, ex. "my-widget")');
|
|
}
|
|
|
|
const packageDir = join(ROOT, 'packages', category, name);
|
|
if (existsSync(packageDir)) {
|
|
fail(`packages/${category}/${name} existe déjà`);
|
|
}
|
|
|
|
const npmName = `@lightweight-sdk/${name}`;
|
|
const isClient = category === 'client';
|
|
|
|
mkdirSync(join(packageDir, 'src'), { recursive: true });
|
|
|
|
const packageJson = {
|
|
name: npmName,
|
|
version: '0.0.1',
|
|
description: `TODO: décrire le package ${name}.`,
|
|
main: 'dist/index.js',
|
|
types: 'dist/index.d.ts',
|
|
files: ['dist'],
|
|
engines: { node: '>=22' },
|
|
publishConfig: {
|
|
access: 'restricted',
|
|
registry: 'https://git.dev.kyuno.fr/api/packages/kyuno/npm/',
|
|
},
|
|
typedocOptions: {
|
|
entryPoints: ['src/index.ts'],
|
|
readme: 'README.md',
|
|
},
|
|
// Déclarées explicitement (même si déjà présentes à la racine, où elles
|
|
// seraient hoistées) : un package workspace sans aucune dépendance propre
|
|
// peut se retrouver sans entrée dans pnpm-lock.yaml, ce qui casse
|
|
// `pnpm install --frozen-lockfile` en CI (voir spec/ci-cd.md).
|
|
devDependencies: {
|
|
'@types/node': 'catalog:',
|
|
typescript: 'catalog:',
|
|
vitest: 'catalog:',
|
|
},
|
|
};
|
|
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify(packageJson, null, '\t')}\n`);
|
|
|
|
const tsconfig = {
|
|
extends: '../../../tsconfig.base.json',
|
|
compilerOptions: isClient
|
|
? { lib: ['ES2022', 'DOM', 'DOM.Iterable'], outDir: './dist', rootDir: './src' }
|
|
: { outDir: './dist', rootDir: './src' },
|
|
include: ['src/**/*'],
|
|
};
|
|
writeFileSync(join(packageDir, 'tsconfig.json'), `${JSON.stringify(tsconfig, null, '\t')}\n`);
|
|
|
|
const indexTs = `/**
|
|
* Placeholder function for the ${npmName} package — replace with real exports.
|
|
* @param subject the name to greet
|
|
* @returns a greeting for the given subject
|
|
*/
|
|
export function greet(subject: string): string {
|
|
return \`Hello, \${subject}!\`;
|
|
}
|
|
`;
|
|
writeFileSync(join(packageDir, 'src', 'index.ts'), indexTs);
|
|
|
|
const indexTest = `import { describe, expect, it } from 'vitest';
|
|
import { greet } from './index';
|
|
|
|
describe('greet', () => {
|
|
it('greets the given subject', () => {
|
|
expect(greet('world')).toBe('Hello, world!');
|
|
});
|
|
});
|
|
`;
|
|
writeFileSync(join(packageDir, 'src', 'index.test.ts'), indexTest);
|
|
|
|
const commonNote =
|
|
category === 'common'
|
|
? "\n> `common/` doit rester isomorphe : pas d'API exclusivement DOM ou exclusivement Node.\n"
|
|
: '';
|
|
const readme = `# ${npmName}
|
|
${commonNote}
|
|
TODO: décrire le package.
|
|
|
|
## Installation
|
|
|
|
\`\`\`bash
|
|
pnpm add ${npmName}
|
|
\`\`\`
|
|
|
|
## Usage
|
|
|
|
TODO
|
|
`;
|
|
writeFileSync(join(packageDir, 'README.md'), readme);
|
|
|
|
// Édite le tsconfig.json racine (project references)
|
|
const rootTsconfigPath = join(ROOT, 'tsconfig.json');
|
|
const rootTsconfig = JSON.parse(readFileSync(rootTsconfigPath, 'utf8'));
|
|
rootTsconfig.references ??= [];
|
|
rootTsconfig.references.push({ path: `./packages/${category}/${name}` });
|
|
writeFileSync(rootTsconfigPath, `${JSON.stringify(rootTsconfig, null, '\t')}\n`);
|
|
|
|
// Ajoute une ligne dans le tableau du registre des packages (pas en fin de
|
|
// fichier : insérée juste après la dernière ligne existante du tableau,
|
|
// même si d'autres sections suivent plus bas dans le fichier).
|
|
const packagesSpecPath = join(ROOT, 'spec', 'packages.md');
|
|
const packagesSpecLines = readFileSync(packagesSpecPath, 'utf8').split('\n');
|
|
const newRow = `| \`${npmName}\` | ${category} | 0.0.1 | brouillon |`;
|
|
const separatorIndex = packagesSpecLines.findIndex((line) => line.startsWith('|---'));
|
|
if (separatorIndex === -1) {
|
|
packagesSpecLines.push(newRow);
|
|
} else {
|
|
let insertAt = separatorIndex + 1;
|
|
while (insertAt < packagesSpecLines.length && packagesSpecLines[insertAt].startsWith('|')) {
|
|
insertAt += 1;
|
|
}
|
|
packagesSpecLines.splice(insertAt, 0, newRow);
|
|
}
|
|
writeFileSync(packagesSpecPath, packagesSpecLines.join('\n'));
|
|
|
|
console.log(`Package ${npmName} créé dans packages/${category}/${name}/.`);
|
|
|
|
try {
|
|
// shell: true est nécessaire sous Windows, où `pnpm` est un shim .cmd/.ps1
|
|
// que execFileSync ne résout pas via PATHEXT sans passer par un shell.
|
|
execFileSync('pnpm', ['install'], { cwd: ROOT, stdio: 'inherit', shell: true });
|
|
execFileSync(
|
|
'pnpm',
|
|
[
|
|
'exec',
|
|
'biome',
|
|
'check',
|
|
'--write',
|
|
`packages/${category}/${name}`,
|
|
'tsconfig.json',
|
|
'spec/packages.md',
|
|
],
|
|
{ cwd: ROOT, stdio: 'inherit', shell: true },
|
|
);
|
|
} catch (error) {
|
|
console.error('Étape automatique échouée, à relancer toi-même :');
|
|
console.error(' pnpm install');
|
|
console.error(
|
|
` pnpm exec biome check --write packages/${category}/${name} tsconfig.json spec/packages.md`,
|
|
);
|
|
console.error(String(error));
|
|
}
|
|
|
|
console.log('\nProchaines étapes :');
|
|
console.log(` cd packages/${category}/${name}`);
|
|
console.log(' - compléter la description dans package.json et README.md');
|
|
console.log(` - pnpm --filter ${npmName} test`);
|
|
console.log(' - pnpm changeset (une fois prêt à publier)');
|