diff --git a/.vscode/settings.json b/.vscode/settings.json index e217e9f..fb49462 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "files.exclude": { - "**/node_modules": true, + // "**/node_modules": true, // "**/dist": true, // "**/plugins": true, } diff --git a/build/build.ts b/build/build.ts deleted file mode 100644 index 7904839..0000000 --- a/build/build.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { type Plugin, type PluginBuild, type BuildOptions, build } from 'esbuild'; -import path from 'node:path'; -import fs from 'node:fs'; -import { glob } from 'glob'; - -const entryPoints = glob.sync('./src/**/*.ts', { - ignore: ['./src/**/*.test.*'], -}); - -/* - This plugin is inspired by the following. - https://github.com/evanw/esbuild/issues/622#issuecomment-769462611 -*/ -const addExtension = (extension: string = '.js', fileExtension: string = '.ts'): Plugin => ({ - name: 'add-extension', - setup(build: PluginBuild) { - build.onResolve({ filter: /.*/ }, (args) => { - - if (args.importer) { - const p = path.join(args.resolveDir, args.path); - let tsPath = `${p}${fileExtension}`; - - let importPath = ''; - if (fs.existsSync(tsPath)) { - importPath = args.path + extension; - } else { - tsPath = path.join(args.resolveDir, args.path, `index${fileExtension}`); - if (fs.existsSync(tsPath)) { - if (args.path.endsWith('/')) { - importPath = `${args.path}index${extension}`; - } else { - importPath = `${args.path}/index${extension}`; - } - } - } - return { path: importPath, external: true }; - } - }); - }, -}); - -const commonOptions: BuildOptions = { - entryPoints, - logLevel: 'info', - platform: 'node', -}; - -const cjsBuild = () => - build({ - ...commonOptions, - outbase: './src', - outdir: './dist/cjs', - format: 'cjs', - }); - -const esmBuild = () => - build({ - ...commonOptions, - bundle: true, - outdir: './dist', - format: 'esm', - plugins: [addExtension('.js')], - }); - -Promise.all([esmBuild(), cjsBuild()]); \ No newline at end of file diff --git a/build/copy-package.ts b/build/copy-package.ts deleted file mode 100644 index 0e7ecb6..0000000 --- a/build/copy-package.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { copyFile, mkdir } from "fs/promises"; -import { dirname } from "path"; - -const source = "package.cjs.json"; -const target = "dist/package.json"; - -await mkdir(dirname(target), { recursive: true }); -await copyFile(source, target); - -console.log(`Copied ${source} -> ${target}`); diff --git a/build/generate-exports.ts b/build/generate-exports.ts deleted file mode 100644 index 68f76cd..0000000 --- a/build/generate-exports.ts +++ /dev/null @@ -1,73 +0,0 @@ -import fs from 'node:fs'; - -const exportsMap: Record = { - // Integrations - nunjucks: 'integration/nunjucks/index', - ejs: 'integration/ejs/index', - static: 'integration/static/index', - session: 'integration/session/index', - integration: 'integration/index', - service: 'service/index', - routes: 'routes/index', - - // Middlewares - cors: 'middleware/cors/index', - 'basic-auth': 'middleware/basic-auth/index', - middleware: 'middleware/index', - - // utils - utils: 'utils/index', - - file: 'context/formdata/file/index', - formdata: 'context/formdata/index', - cookies: 'context/cookies/index', - - exception: 'error/index', - headers: 'headers/index', - - block: 'block/index', - response: 'response', - next: 'next', - types: 'types', - app: 'gaman-app' -}; - -const exportsField: Record = { - '.': { - types: './dist/index.d.ts', - import: './dist/index.js', - require: './dist/cjs/index.js', - }, -}; - -const typesVersions: Record> = { - '*': {}, -}; - -for (const [subpath, file] of Object.entries(exportsMap)) { - const typesPath = `./dist/${file}.d.ts`; - const importPath = `./dist/${file}.js`; - const requirePath = `./dist/cjs/${file}.js`; - - // Tambahkan ke exports - exportsField[`./${subpath}`] = { - types: typesPath, - import: importPath, - require: requirePath, - }; - - // Tambahkan ke typesVersions - typesVersions['*'][subpath] = [typesPath.replace('.d.ts', '')]; -} - -// Baca package.json -const pkgPath = './package.json'; -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - -// Update fields -pkg.exports = exportsField; -pkg.typesVersions = typesVersions; - -// Tulis ulang package.json -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); -console.log('✅ Generated exports & typesVersions in package.json'); diff --git a/build/generate-key.ts b/build/generate-key.ts deleted file mode 100644 index 8316f42..0000000 --- a/build/generate-key.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { randomBytes } from 'crypto'; -import path from 'path'; -import fs from 'fs/promises'; - -const envPath = path.join(process.cwd(), '.env'); -const gamanKey = randomBytes(32).toString('hex'); - -await fs.writeFile(envPath, `GAMAN_KEY=${gamanKey}\n`); -console.log('🔐 Generated GAMAN_KEY and saved to .env'); diff --git a/build/gzip-build.ts b/build/gzip-build.ts deleted file mode 100644 index 7f044f3..0000000 --- a/build/gzip-build.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createBrotliCompress, createGzip } from 'node:zlib'; -import { pipeline } from 'node:stream/promises'; -import fs from 'fs'; -import { glob } from 'glob'; -import path from 'node:path'; - -const compressFile = async (inputPath: string) => { - const gzipPath = `${inputPath}.gz`; - const brPath = `${inputPath}.br`; - - await Promise.all([ - pipeline(fs.createReadStream(inputPath), createGzip(), fs.createWriteStream(gzipPath)), - pipeline(fs.createReadStream(inputPath), createBrotliCompress(), fs.createWriteStream(brPath)), - ]); -}; - -const compressDistFiles = async () => { - const exts = ['.js', '.css', '.html', '.json']; - const files = glob.sync('./dist/**/*.*', { - ignore: ['**/*.gz', '**/*.br'], - }); - for (const file of files) { - if (exts.includes(path.extname(file))) { - await compressFile(file); - console.log(`Compressed: ${file}`); - } - } -}; - -await compressDistFiles(); diff --git a/package.json b/package.json index 1d1482d..a352d5a 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dependencies": { "@types/node": "^24.3.1", "@types/ws": "^8.18.1", + "cac": "^6.7.14", "chokidar": "^3.6.0", "cookie": "^1.0.2", "edge.js": "^6.3.0", diff --git a/packages/cli/builder/config-generator.ts b/packages/cli/builder/config-generator.ts new file mode 100644 index 0000000..149a061 --- /dev/null +++ b/packages/cli/builder/config-generator.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "fs"; + +function extractStaticServePath(filePath: string): string { + const content = readFileSync(filePath, "utf8"); + + // Cari pattern: staticServe({ path: "..." }) + const match = content.match(/staticServe\s*\(\s*\{\s*path\s*:\s*["'`](.*?)["'`]/); + + if (match && match[1]) { + return match[1]; // nilai path ditemukan + } + + // Kalau gak ketemu, return default + return "/public"; +} \ No newline at end of file diff --git a/packages/cli/builder/gzip-build.ts b/packages/cli/builder/gzip-build.ts new file mode 100644 index 0000000..1a34bdb --- /dev/null +++ b/packages/cli/builder/gzip-build.ts @@ -0,0 +1,42 @@ +import { createBrotliCompress, createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; +import fs from 'fs'; +import { glob } from 'glob'; +import path from 'node:path'; +import { GamanConfig } from '@gaman/core'; + +const compressFile = async (inputPath: string) => { + const gzipPath = `${inputPath}.gz`; + const brPath = `${inputPath}.br`; + + await Promise.all([ + pipeline( + fs.createReadStream(inputPath), + createGzip(), + fs.createWriteStream(gzipPath), + ), + pipeline( + fs.createReadStream(inputPath), + createBrotliCompress(), + fs.createWriteStream(brPath), + ), + ]); +}; + +const exts = ['.js', '.mjs', '.css', '.html', '.json', '.svg', '.xml', '.txt']; + +/** + * @ID Compress semua file yang ada di folder dist/client seperti .js jadi .js.br atau .gzip + * @param config + */ +export const compressDistFiles = async (config: GamanConfig) => { + const files = glob.sync(`./${config.build?.outdir || 'dist'}/client/**/*.*`, { + ignore: ['**/*.gz', '**/*.br'], + }); + for (const file of files) { + if (exts.includes(path.extname(file))) { + await compressFile(file); + console.log(`Compressed: ${file}`); + } + } +}; diff --git a/packages/cli/builder/helper.ts b/packages/cli/builder/helper.ts new file mode 100644 index 0000000..d934313 --- /dev/null +++ b/packages/cli/builder/helper.ts @@ -0,0 +1,28 @@ +import fs, { existsSync } from 'fs'; +import path from 'path'; + +export const isDevelopment = (outdir: string) => { + return existsSync(path.join(outdir, '.development')); +}; + +/** + * ini akan membuat file `.development` di folder /dist atau folder tujuan build jadinya + * file ini menandakan bahwa build an tersebut itu di mode development + */ +export const createDevelopmentFile = (outdir: string) => { + fs.mkdirSync(outdir, { recursive: true }); + const filePath = path.join(outdir, '.development'); + const content = `# GamanJS Development Mode\nCreated at: ${new Date().toISOString()}\n`; + fs.writeFileSync(filePath, content, 'utf-8'); +}; + +/** + * Ini akan membuat file `.production` di folder /dist atau folder tujuan build jadi nya + * file ini menandakan bahwa build an tersebut itu di mode production + */ +export const createProductionFile = (outdir: string) => { + fs.mkdirSync(outdir, { recursive: true }); + const filePath = path.join(outdir, '.production'); + const content = `# GamanJS Production Mode\nCreated at: ${new Date().toISOString()}\n`; + fs.writeFileSync(filePath, content, 'utf-8'); +}; diff --git a/packages/cli/builder/index.ts b/packages/cli/builder/index.ts new file mode 100644 index 0000000..54177d9 --- /dev/null +++ b/packages/cli/builder/index.ts @@ -0,0 +1,138 @@ +import { GamanConfig } from '@gaman/core/config/index.js'; +import fs, { existsSync, rmSync } from 'fs'; +import path from 'path'; +import esbuild from 'esbuild'; +import { Logger } from '@gaman/common/index.js'; +import fg from 'fast-glob'; +import { addJsExtensionPlugin } from './plugins/addJsExtensionPlugin.js'; +import { createDevelopmentFile, createProductionFile } from './helper.js'; +import { promisify } from 'util'; +import { compressDistFiles } from './gzip-build.js'; + +const mkdir = promisify(fs.mkdir); +const copyFile = promisify(fs.copyFile); +const readdir = promisify(fs.readdir); + +/** + * ? Rekursif copy folder dan file + */ +async function copyRecursive(src: string, dest: string, verbose?: boolean) { + const entries = await readdir(src, { withFileTypes: true }); + await mkdir(dest, { recursive: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + await copyRecursive(srcPath, destPath, verbose); + } else { + await copyFile(srcPath, destPath); + if (verbose) Logger.debug(`Copied: ${srcPath} → ${destPath}`); + } + } +} + +/** + * ? Copy folder public → dist/client + */ +async function copyPublicToClient(config: GamanConfig) { + const staticDir = config.build?.staticdir || 'public'; + const srcDir = path.resolve(staticDir); + const outDir = path.resolve(`${config.build?.outdir || 'dist'}/client/${staticDir}`); + + if (!existsSync(srcDir)) { + Logger.warn('Folder "public" not found!, skip copy.'); + return; + } + + await copyRecursive(srcDir, outDir, config.verbose); + Logger.log(`Copied ${staticDir} → ${outDir}`); +} + +/** + * @ID + * Ini untuk membuild semua file saat pertama kali `npm run dev` atau `npm run build` + */ +export const buildAll = async ( + config: GamanConfig, + mode: 'development' | 'production', +) => { + const outdir = config.build?.outdir || 'dist'; + const verbose = config.verbose; + + // ! Bersihkan folder sebelumnya + if (existsSync(outdir)) { + if (verbose) Logger.debug('Cleaning previous build...'); + rmSync(outdir, { recursive: true, force: true }); + } + + // ! Generate mode file .development or .production + if (mode === 'development') { + createDevelopmentFile(outdir); + if (verbose) Logger.debug('Development file created.'); + } else { + createProductionFile(outdir); + if (verbose) Logger.debug('Production file created.'); + + // ? Copy folder public → dist/client + await copyPublicToClient(config); + // ? compress file .js .html yang di dalam dist/client + await compressDistFiles(config); + } + + // ? Cari entry file + if (verbose) Logger.debug('Searching entry points...'); + const entryPoints = await fg(config.build?.includes ?? ['src/**/*.{ts,js}'], { + ignore: config.build?.excludes, + }); + if (verbose) Logger.debug(`Found ${entryPoints.length} entry files`); + + // ? Build semua file secara paralel + await Promise.all( + entryPoints.map(async (file) => { + try { + await buildFile(file, config, mode); + } catch (err) { + Logger.error(`Build failed: ${file}`); + if (verbose) console.error(err); + } + }), + ); +}; + +/** + * @ID + * Build satu file (dipakai di watcher dev) + */ +export const buildFile = async ( + file: string, + config: GamanConfig, + mode: 'development' | 'production', +) => { + const outdir = `${config.build?.outdir}/server`; + const rootdir = config.build?.rootdir || 'src'; + const relPath = path.relative(rootdir, file).replaceAll('\\', '/'); + const outFile = path.join(outdir, relPath).replace(/\.(ts|js)$/, '.js'); + + await esbuild.build({ + entryPoints: [file], + outfile: outFile, + bundle: false, + format: 'esm', + platform: 'node', + target: 'node18', + allowOverwrite: true, + minify: mode === 'production', + sourcemap: mode === 'development', + legalComments: 'none', + packages: 'external', + alias: config?.build?.alias, + define: { + 'process.env.NODE_ENV': `"${mode}"`, + }, + plugins: [addJsExtensionPlugin, ...(config?.build?.esbuildPlugins || [])], + }); + + if (config.verbose) Logger.debug(`Built: ${relPath}`); +}; diff --git a/packages/cli/builder/plugins/addJsExtensionPlugin.ts b/packages/cli/builder/plugins/addJsExtensionPlugin.ts new file mode 100644 index 0000000..bb82b90 --- /dev/null +++ b/packages/cli/builder/plugins/addJsExtensionPlugin.ts @@ -0,0 +1,31 @@ +import { Plugin } from 'esbuild'; +import path from 'path'; +import fs from 'fs'; + +export const addJsExtensionPlugin: Plugin = { + name: 'add-js-extension', + setup(build) { + build.onLoad({ filter: /\.[jt]s$/ }, async (args) => { + const fsp = await import('fs/promises'); + let contents = await fsp.readFile(args.path, 'utf8'); + + contents = contents.replace(/from\s+['"](\..*?)['"]/g, (match, p1) => { + const absImportPath = path.resolve(path.dirname(args.path), p1); + if (p1.endsWith('.js')) return match; + + let newPath = p1; + if (fs.existsSync(absImportPath + '.ts')) { + newPath = p1 + '.js'; + } else { + const indexTs = path.join(absImportPath, 'index.ts'); + if (fs.existsSync(indexTs)) { + newPath = p1.endsWith('/') ? p1 + 'index.js' : p1 + '/index.js'; + } + } + return `from "${newPath}"`; + }); + + return { contents, loader: args.path.endsWith('.ts') ? 'ts' : 'js' }; + }); + }, +}; diff --git a/packages/cli/command/build.ts b/packages/cli/command/build.ts index d5a0871..bd1d233 100644 --- a/packages/cli/command/build.ts +++ b/packages/cli/command/build.ts @@ -1,20 +1,16 @@ import { Command } from './command.js'; import { getGamanConfig, Logger, TextFormat } from '@gaman/common/index.js'; -import { buildAll } from '../utils/esbuild.js'; +import { buildAll } from '../builder/index.js'; -export class BuildCommand extends Command { - constructor() { - super('build', 'Build the application', 'gaman build', []); - } +export async function run_build(): Promise { + const start = Date.now(); + Logger.debug('Build Started...'); + const config = await getGamanConfig(); + await buildAll({ ...config, verbose: true }, 'production'); - async execute(): Promise { - const start = Date.now(); - Logger.debug('Build Started...'); - const config = await getGamanConfig(); - await buildAll({ ...config, verbose: true }, 'production'); - - Logger.debug(`Build finished in${TextFormat.GREEN} ${Date.now() - start}ms${TextFormat.RESET}`); - } + Logger.debug( + `Build finished in${TextFormat.GREEN} ${Date.now() - start}ms${ + TextFormat.RESET + }`, + ); } - -export default new BuildCommand(); diff --git a/packages/cli/command/dev.ts b/packages/cli/command/dev.ts index 7f49526..d41ad0a 100644 --- a/packages/cli/command/dev.ts +++ b/packages/cli/command/dev.ts @@ -5,87 +5,80 @@ import { Logger } from '@gaman/common/utils/logger.js'; import chokidar from 'chokidar'; import path from 'path'; import { getGamanConfig } from '@gaman/common/index.js'; -import { buildAll, buildFile } from '../utils/esbuild.js'; +import { buildAll, buildFile } from '../builder/index.js'; -export class DevCommand extends Command { - constructor() { - super('dev', 'Run the application in development mode', 'gaman dev', []); - } +export async function run_dev(): Promise { + const config = await getGamanConfig(); + const outdir = `${config.build?.outdir}/server`; + const verbose = config.verbose; + const rootdir = config.build?.rootdir || 'src'; + const entryFile = path.join(outdir, 'index.js'); - async execute(): Promise { - const config = await getGamanConfig(); - const outdir = config.build?.outdir || 'dist'; - const verbose = config.verbose; - const rootdir = config.build?.rootdir || 'src'; - const entryFile = path.join(outdir, 'index.js'); + // ? awal awal build semua file + await buildAll(config, 'development'); - await buildAll(config, 'development'); + let child: ChildProcess | null = null; + const restart = () => { + if (child) { + Logger.log('Restarting application...'); + child.kill(); + } - let child: ChildProcess | null = null; - const restart = () => { - if (child) { - Logger.log('Restarting application...'); - child.kill(); - } - child = spawn( - process.execPath, - [entryFile, ...process.argv.slice(3)], - { - stdio: 'inherit', - }, - ); - }; + const devIndex = process.argv.indexOf('dev'); + const extraArgs = devIndex >= 0 ? process.argv.slice(devIndex + 1) : []; + child = spawn(process.execPath, [entryFile, ...extraArgs], { + stdio: 'inherit', + env: process.env, + }); + }; - restart(); + restart(); - let changeTimeout: NodeJS.Timeout | null = null; - chokidar - .watch(rootdir, { - ignored: ['**/node_modules/**', `**/${outdir}/**`], - }) - .on('add', async (file) => { - if (/\.(ts|js)$/.test(file)) { - if (verbose) Logger.debug(`New file: ${file}`); + let changeTimeout: NodeJS.Timeout | null = null; + chokidar + .watch([rootdir, 'gaman.config.mjs', '.env'], { + ignored: config?.build?.excludes, + }) + .on('add', async (file) => { + if (/\.(ts|js)$/.test(file)) { + if (verbose) Logger.debug(`New file: ${file}`); + try { + await buildFile(file, config, 'development'); + } catch (err) { + Logger.error(`Build error: ${err}`); + } + } + }) + .on('change', (file) => { + if (/\.(ts|js)$/.test(file)) { + if (changeTimeout) clearTimeout(changeTimeout); + changeTimeout = setTimeout(async () => { + if (verbose) Logger.debug(`Changed: ${file}`); try { await buildFile(file, config, 'development'); + restart(); } catch (err) { Logger.error(`Build error: ${err}`); } - } - }) - .on('change', (file) => { - if (/\.(ts|js)$/.test(file)) { - if (changeTimeout) clearTimeout(changeTimeout); - changeTimeout = setTimeout(async () => { - if (verbose) Logger.debug(`Changed: ${file}`); - try { - await buildFile(file, config, 'development'); - restart(); - } catch (err) { - Logger.error(`Build error: ${err}`); - } - }, 100); // tunggu 100ms - } - }) - .on('unlink', async (file) => { - const relPath = path.relative(rootdir, file); - const outBase = path.join(outdir, relPath).replace(/\.(ts|js)$/, ''); - const filesToRemove = [ - outBase + '.js', - outBase + '.js.map', - outBase + '.d.ts', - ]; - - filesToRemove.forEach((f) => { - if (existsSync(f)) { - unlinkSync(f); - if (verbose) Logger.debug(`Removed: ${f}`); - } - }); + }, 100); // tunggu 100ms + } + }) + .on('unlink', async (file) => { + const relPath = path.relative(rootdir, file); + const outBase = path.join(outdir, relPath).replace(/\.(ts|js)$/, ''); + const filesToRemove = [ + outBase + '.js', + outBase + '.js.map', + outBase + '.d.ts', + ]; - restart(); + filesToRemove.forEach((f) => { + if (existsSync(f)) { + unlinkSync(f); + if (verbose) Logger.debug(`Removed: ${f}`); + } }); - } -} -export default new DevCommand(); + restart(); + }); +} diff --git a/packages/cli/command/start.ts b/packages/cli/command/start.ts index f646cca..b95763e 100644 --- a/packages/cli/command/start.ts +++ b/packages/cli/command/start.ts @@ -1,55 +1,38 @@ import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { Logger } from '@gaman/common/utils/logger.js'; -import { Command } from './command.js'; import { getGamanConfig, TextFormat } from '@gaman/common/index.js'; import path from 'path'; -import { isDevelopment } from '../utils/esbuild.js'; +import { isDevelopment } from '../builder/helper.js'; -// Versi sebagai Command -export class StartCommand extends Command { - constructor() { - super( - 'start', - 'Start the application in production mode', - 'gaman start', - [], +export async function run_start(): Promise { + const config = await getGamanConfig(); + const outdir = `${config.build?.outdir || 'dist'}/server`; + const entryFile = path.join(outdir, 'index.js'); + + if (isDevelopment(outdir)) { + Logger.error( + `You are in Development mode, please${TextFormat.GREEN} npm run build${TextFormat.RED} first.${TextFormat.RESET}`, ); + process.exit(1); } - async execute(): Promise { - const config = await getGamanConfig(); - const outdir = config.build?.outdir || 'dist'; - const entryFile = path.join(outdir, 'index.js'); - - if (isDevelopment(outdir)) { - Logger.error( - `You are in Development mode, please${TextFormat.GREEN} npm run build${TextFormat.RED} first.${TextFormat.RESET}`, - ); - process.exit(1); - } - - if (!existsSync(entryFile)) { - Logger.error( - `File ${outdir}/index.js not found. Please run the build process first.`, - ); - process.exit(1); - } - - const child = spawn( - process.execPath, - [entryFile, ...process.argv.slice(3)], - { - stdio: 'inherit', - env: process.env, - }, + if (!existsSync(entryFile)) { + Logger.error( + `File ${outdir}/index.js not found. Please run the build process first.`, ); - - child.on('exit', (code) => { - Logger.error(`Process exited with code: ${code}`); - process.exit(code ?? 0); - }); + process.exit(1); } -} -export default new StartCommand(); + const devIndex = process.argv.indexOf('start'); + const extraArgs = devIndex >= 0 ? process.argv.slice(devIndex + 1) : []; + const child = spawn(process.execPath, [entryFile, ...extraArgs], { + stdio: 'inherit', + env: process.env, + }); + + child.on('exit', (code) => { + Logger.error(`Process exited with code: ${code}`); + process.exit(code ?? 0); + }); +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 744cf14..f61a6fd 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,73 +1,14 @@ #!/usr/bin/env tsx +import { cac } from 'cac'; +import { run_dev } from './command/dev.js'; +import { run_build } from './command/build.js'; +import { run_start } from './command/start.js'; -import dev from './command/dev.js'; -import build from './command/build.js'; -import start from './command/start.js'; -import { Command } from './command/command.js'; -import makeBlock from './command/make/make-block.js'; -import makeIntegration from './command/make/make-integration.js'; -import makeMiddleware from './command/make/make-middleware.js'; -import makeRoutes from './command/make/make-routes.js'; -import { TextFormat } from '@gaman/common/utils/textformat.js'; -import makeService from './command/make/make-service.js'; -import makeModule from './command/make/make-module.js'; -import { parseArgs } from '@gaman/common/index.js'; +const cli = cac('gaman'); +cli.command('dev', 'Start development server').action(run_dev); +cli.command('build', 'Build the application').action(run_build); +cli.command('start', 'Start the application in production mode').action(run_start); -const commands: Command[] = [ - dev, - build, - start, - makeModule, - makeBlock, - makeRoutes, - makeService, - makeIntegration, - makeMiddleware, -]; - -// Parsing argumen CLI -const { command, args } = parseArgs(); - -// Fungsi menampilkan bantuan -function showHelp() { - console.log(TextFormat.format(`§l§bGaman CLI§r\n`)); - - console.log( - TextFormat.format(`§eUsage:\n §r§a$ gaman [options]\n`), - ); - console.log(TextFormat.format(`§eCommands:`)); - - for (const cmd of commands) { - const aliases = - cmd.alias.length > 0 ? ` (alias: ${cmd.alias.join(', ')})` : ''; - - console.log( - TextFormat.format( - ` §b gaman ${cmd.name.padEnd(16)}§r ${cmd.description}${ - aliases ? ` §8${aliases}` : '' - }`, - ), - ); - } - - console.log(); -} - -(async () => { - if (!command || command === 'help' || args.help || args.h) { - showHelp(); - return; - } - - const matched = commands.find((cmd) => - [cmd.name, ...cmd.alias].includes(command), - ); - - if (matched) { - await matched.execute(args); - } else { - console.error(`Unknown command: ${command}`); - showHelp(); - } -})(); +cli.help(); +cli.parse(); diff --git a/packages/cli/package.json b/packages/cli/package.json index cbed35c..061eb9e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,5 +25,8 @@ "registry": "https://registry.npmjs.org", "access": "public" }, + "peerDependencies": { + "cac": "^6.7.14" + }, "gitHead": "f3c12a3d4b0f5638afca69b3bf3f742a8e5a9997" } diff --git a/packages/cli/utils/esbuild.ts b/packages/cli/utils/esbuild.ts deleted file mode 100644 index d1ed116..0000000 --- a/packages/cli/utils/esbuild.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { GamanConfig } from '@gaman/core/config/index.js'; -import type { Plugin } from 'esbuild'; -import fs, { existsSync, rmSync } from 'fs'; -import path from 'path'; -import esbuild from 'esbuild'; -import { Logger } from '@gaman/common/index.js'; -import fg from 'fast-glob'; - -export const addJsExtensionPlugin: Plugin = { - name: 'add-js-extension', - setup(build) { - build.onLoad({ filter: /\.[jt]s$/ }, async (args) => { - const fsp = await import('fs/promises'); - let contents = await fsp.readFile(args.path, 'utf8'); - - contents = contents.replace(/from\s+['"](\..*?)['"]/g, (match, p1) => { - const absImportPath = path.resolve(path.dirname(args.path), p1); - if (p1.endsWith('.js')) return match; - - let newPath = p1; - if (fs.existsSync(absImportPath + '.ts')) { - newPath = p1 + '.js'; - } else { - const indexTs = path.join(absImportPath, 'index.ts'); - if (fs.existsSync(indexTs)) { - newPath = p1.endsWith('/') ? p1 + 'index.js' : p1 + '/index.js'; - } - } - return `from "${newPath}"`; - }); - - return { contents, loader: args.path.endsWith('.ts') ? 'ts' : 'js' }; - }); - }, -}; - -export const isDevelopment = (outdir: string) => { - return existsSync(path.join(outdir, '.development')); -}; - -export const createDevelopmentFile = (outdir: string) => { - fs.mkdirSync(outdir, { recursive: true }); - const filePath = path.join(outdir, '.development'); - const content = `# GamanJS Development Mode\nCreated at: ${new Date().toISOString()}\n`; - fs.writeFileSync(filePath, content, 'utf-8'); -}; - -export const createProductionFile = (outdir: string) => { - fs.mkdirSync(outdir, { recursive: true }); - const filePath = path.join(outdir, '.production'); - const content = `# GamanJS Production Mode\nCreated at: ${new Date().toISOString()}\n`; - fs.writeFileSync(filePath, content, 'utf-8'); -}; - -export const buildAll = async ( - config: GamanConfig, - mode: 'development' | 'production', -) => { - const outdir = config.build?.outdir || 'dist'; - const verbose = config.verbose; - if (existsSync(outdir)) { - if (verbose) Logger.debug('Cleaning previous build...'); - rmSync(outdir, { recursive: true, force: true }); - } - if (mode === 'development') { - createDevelopmentFile(outdir); - if (verbose) Logger.debug('Development file created.'); - } else { - createProductionFile(outdir); - if (verbose) Logger.debug('Production file created.'); - } - - if (verbose) Logger.debug('Searching entry points...'); - const entryPoints = await fg(config.build?.includes || ['src/**/*.{ts,js}'], { - ignore: config.build?.excludes || [ - '**/node_modules/**', - `**/${outdir}/**`, - '**/*.test.*', - '**/*.d.*', - ], - }); - if (verbose) Logger.debug(`Found ${entryPoints.length} entry files`); - - // start build - for (const file of entryPoints) { - await buildFile(file, config, mode); - } -}; - -export const buildFile = async ( - file: string, - config: GamanConfig, - mode: 'development' | 'production', -) => { - const outdir = config.build?.outdir || 'dist'; - const rootdir = config.build?.rootdir || 'src'; - const relPath = path.relative(rootdir, file); - const outFile = path.join(outdir, relPath).replace(/\.(ts|js)$/, '.js'); - - await esbuild.build({ - entryPoints: [file], - outfile: outFile, - bundle: false, - format: 'esm', - platform: 'node', - target: 'node18', - allowOverwrite: true, - minify: mode === 'production', - sourcemap: true, - legalComments: 'none', - packages: 'external', - alias: config?.build?.alias, - define: { - 'process.env.NODE_ENV': `"${mode}"`, - }, - plugins: [addJsExtensionPlugin, ...(config?.build?.esbuildPlugins || [])], - }); - if (config.verbose) Logger.debug(`Built: ${relPath}`); -}; diff --git a/packages/common/utils/get-config.ts b/packages/common/utils/get-config.ts index c3eefaf..36dad78 100644 --- a/packages/common/utils/get-config.ts +++ b/packages/common/utils/get-config.ts @@ -1,16 +1,35 @@ import { GamanConfig } from '@gaman/core/config/index.js'; -let config; +const defaultConfig: GamanConfig = { + verbose: false, + build: { + outdir: 'dist', + rootdir: 'src', + staticdir: 'public', + excludes: ['**/node_modules/**', '**/dist/**', '**/*.test.*'], + includes: ['src/**/*.{ts,js}'], + }, +}; +let config; export async function getGamanConfig(): Promise { - if (config) { - return config; - } + if (config) return config; + try { const cfg = await import(`${process.cwd()}/gaman.config.mjs`); - config = cfg.default || {}; + const userConfig = cfg.default || {}; + + config = { + ...defaultConfig, + ...userConfig, + build: { + ...defaultConfig.build, + ...userConfig.build, + }, + }; + return config; } catch (error) { - return {}; + return defaultConfig; } } diff --git a/packages/core/config/index.ts b/packages/core/config/index.ts index 15aee8a..0c49187 100644 --- a/packages/core/config/index.ts +++ b/packages/core/config/index.ts @@ -21,6 +21,11 @@ export interface GamanBuildConfig { */ rootdir?: string; + /** + * @ID Static file folder + */ + staticdir?: string; + /** * @ID Kustomisasi esbuild plugins jika builder memakai `esbuild` */ diff --git a/packages/core/gaman-app.ts b/packages/core/gaman-app.ts index 51c7ad6..8250ed8 100644 --- a/packages/core/gaman-app.ts +++ b/packages/core/gaman-app.ts @@ -18,7 +18,6 @@ import middlewareData from '@gaman/common/data/middleware-data.js'; import exceptionData from '@gaman/common/data/exception-data.js'; import routesData from '@gaman/common/data/routes-data.js'; - export class GamanApp extends Router { private server?: http.Server< typeof http.IncomingMessage, @@ -45,36 +44,38 @@ export class GamanApp extends Router { http.Server > { const { args } = parseArgs(); - const DEFAULT_HOST = - args['host'] === true + args.host === true ? '0.0.0.0' - : args['host'] || process.env.HOST || '127.0.0.1'; + : args.host || process.env.HOST || '127.0.0.1'; const DEFAULT_PORT = - args['port'] || - (process.env.PORT ? parseInt(process.env.PORT, 10) : 3431); + args.port || (process.env.PORT ? parseInt(process.env.PORT, 10) : 3431); let host = DEFAULT_HOST; let port = DEFAULT_PORT; if (address) { const [h, p] = address.split(':'); - if (h) host = h; if (p) port = parseInt(p, 10); } - return new Promise< - http.Server - >((resolve, reject) => { + return new Promise((resolve, reject) => { try { const server = http.createServer(this.requestHandle.bind(this)); - this.server = server; + + const noServer = process.argv.includes('--no-server'); + if (noServer || process.env.NO_SERVER === 'true') { + // ! kalau dia pakai flag --no-server maka server tidak akan pernah di listen + return resolve(server); + } + this.server.listen(port, host == true ? '0.0.0.0' : `${host}`, () => { resolve(server); - }); + });0 + this.server.on('error', reject); } catch (err) { reject(err); diff --git a/plugins/static/index.ts b/plugins/static/index.ts index 49fdc08..5a28440 100644 --- a/plugins/static/index.ts +++ b/plugins/static/index.ts @@ -1,23 +1,17 @@ import { createReadStream, promises as fsPromises, Stats, statSync } from 'fs'; import { join } from 'path'; -import { Response } from '@gaman/core/response.js'; import * as crypto from 'crypto'; import { DefaultMiddlewareOptions, detectMime, + getGamanConfig, Log, Priority, } from '@gaman/common'; -import { composeMiddleware } from '@gaman/core'; +import { composeMiddleware, Response } from '@gaman/core'; +import { isDevelopment } from '@gaman/cli/builder/helper.js'; -// Tipe opsi konfigurasi middleware export interface StaticFileOptions extends DefaultMiddlewareOptions { - /** - * @ID direktory path (default: /public) - * @EN directory path (default: /public) - */ - path?: string; - /** * @ID kustom mime type konten (contoh: { 'css': 'text/css' }) * @EN custom content mime type (example: { 'css': 'text/css' }) @@ -65,7 +59,7 @@ export interface StaticFileOptions extends DefaultMiddlewareOptions { */ cacheControl?: string; - /** + /**public * @ID Jika `true`, fallback ke `index.html` untuk SPA. * @EN If `true`, return to `index.html` for SPA. */ @@ -90,7 +84,6 @@ function generateETag(stat: { size: number; mtime: Date }) { * - ETag generation for efficient caching (supports 304 Not Modified) * * ## Options - * - `path`: Root directory of static assets. Default is `public`. * - `mimes`: Custom MIME types. You can map file extensions manually. * - `priority`: Determines execution order. Use `'very-high'` if you want static to run early. * - `defaultDocument`: Filename to serve when a directory is requested (default: `index.html`). @@ -103,7 +96,6 @@ function generateETag(stat: { size: number; mtime: Date }) { * ## Example * ```ts * staticServe({ - * path: 'assets', * rewriteRequestPath: (p) => p.replace(/^\/static/, ''), * fallbackToIndexHTML: true, * mimes: { @@ -113,7 +105,7 @@ function generateETag(stat: { size: number; mtime: Date }) { * ``` */ export function staticServe(options: StaticFileOptions = {}) { - const staticPath = options.path || 'public'; + let staticPath; const defaultDocument = options.defaultDocument ?? 'index.html'; const cacheControl = options.cacheControl ?? 'public, max-age=3600'; @@ -124,6 +116,17 @@ export function staticServe(options: StaticFileOptions = {}) { if (options.rewriteRequestPath) { reqPath = options.rewriteRequestPath(reqPath); } + if (!staticPath) { + const config = await getGamanConfig(); + staticPath = config.build?.staticdir || 'public'; // ? init staticPath for (development) + + if (!isDevelopment(config.build?.outdir || 'dist')) { + /** + * if on production mode staticPath like this: /dist/client/public + */ + staticPath = join(config.build?.outdir || 'dist', 'client', staticPath); + } + } let filePath = join(process.cwd(), staticPath, reqPath); let stats: Stats; @@ -157,8 +160,8 @@ export function staticServe(options: StaticFileOptions = {}) { Log.setMethod(''); Log.setStatus(null); - // Gzip/Brotli: cek Accept-Encoding dan cari file terkompresi - const acceptEncoding = ctx.request.headers.get('accept-encoding') || ''; + // ? Gzip/Brotli: cek Accept-Encoding dan cari file terkompresi + const acceptEncoding = ctx.request.header('accept-encoding') || ''; let encoding: 'br' | 'gzip' | null = null; let encodedFilePath = filePath; @@ -177,9 +180,13 @@ export function staticServe(options: StaticFileOptions = {}) { } //? Buat ETag dan handle conditional GET - const etag = generateETag(statSync(encodedFilePath)); - if (ctx.request.headers.get('if-none-match') === etag) { - return Response.text('', { status: 304 }); + let statForEtag = stats; + try { + statForEtag = statSync(encodedFilePath); + } catch {} + const etag = generateETag(statForEtag); + if (ctx.request.header('if-none-match') === etag) { + return Response.notModified(); } const contentType = @@ -199,7 +206,7 @@ export function staticServe(options: StaticFileOptions = {}) { }); }); return middleware({ - priority: options.priority === undefined ? Priority.MONITOR : options.priority, + priority: options.priority ?? Priority.MONITOR, includes: options.includes, excludes: options.excludes, }); diff --git a/test/package-lock.json b/test/package-lock.json index 8f2186e..bc209e4 100644 --- a/test/package-lock.json +++ b/test/package-lock.json @@ -38,6 +38,9 @@ }, "engines": { "node": ">=18.0.0" + }, + "peerDependencies": { + "cac": "^6.7.14" } }, ".yalc/@gaman/common": { @@ -73,7 +76,7 @@ "license": "MIT" }, ".yalc/@gaman/edge": { - "version": "0.0.1", + "version": "1.0.0", "license": "MIT", "peerDependencies": { "edge.js": "*" @@ -885,6 +888,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/case-anything": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-3.1.2.tgz", diff --git a/test/public/bruh/index.html b/test/public/bruh/index.html new file mode 100644 index 0000000..77acf17 --- /dev/null +++ b/test/public/bruh/index.html @@ -0,0 +1,11 @@ + + + + + + Document + + +

aduhai

+ + \ No newline at end of file diff --git a/test/src/index.ts b/test/src/index.ts index a213e0a..6280ac8 100644 --- a/test/src/index.ts +++ b/test/src/index.ts @@ -6,8 +6,6 @@ import AppMiddleware from './middlewares/AppMiddleware'; import { cors } from '@gaman/cors'; import { WebsocketGateway } from '@gaman/websocket'; import { session } from '@gaman/session'; -import { nunjucks } from '@gaman/nunjucks'; -import { rateLimit } from '@gaman/rate-limit'; import { edge } from '@gaman/edge'; import EdgeHandler from './EdgeHandler'; import { jwt } from '@gaman/jwt'; diff --git a/test/yalc.lock b/test/yalc.lock index f2989a9..0882b56 100644 --- a/test/yalc.lock +++ b/test/yalc.lock @@ -2,15 +2,15 @@ "version": "v1", "packages": { "@gaman/core": { - "signature": "f13b58a2bc95835fed24f0176cba7c7f", + "signature": "be7510316f026fdc9e8b194e2d023f51", "file": true }, "@gaman/common": { - "signature": "93dbe54dd3c65191ee37545db22daaae", + "signature": "bad4106056f6c4250c16b939d07e9ec7", "file": true }, "@gaman/cli": { - "signature": "f815097e4461e0ec86ec9ed065d5a013", + "signature": "1a6028f64cbf99468947080b442104c5", "file": true }, "@gaman/cors": { @@ -31,7 +31,7 @@ "file": true }, "@gaman/static": { - "signature": "71dc2aadb6d62bfbc02e5ae7c9671cfe", + "signature": "84585d03905e186d8e73967b108fd076", "file": true }, "@gaman/websocket": { @@ -43,11 +43,11 @@ "file": true }, "@gaman/rate-limit": { - "signature": "b25f5aa4e991b31c38ea60853cb456d4", + "signature": "9551807548135657805733927561352a", "file": true }, "@gaman/edge": { - "signature": "6f628498569ae92529100d1197ea8145", + "signature": "119166e5dc9244c2f7d792294b52037d", "file": true } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 7a6b428..2d48bcf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,7 +2,8 @@ "compilerOptions": { "composite": true, "module": "nodenext", - "target": "ES2020", + "target": "ES2021", + "lib": ["ES2021"], "moduleResolution": "nodenext", "declaration": true, "noImplicitAny": false, @@ -22,7 +23,7 @@ "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "strictPropertyInitialization": false, - "types": ["node"], + "types": ["node"] }, "jest": {}, "tsc-alias": { diff --git a/tsconfig.json b/tsconfig.json index 6824cf9..b1cf274 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "composite": true, "module": "nodenext", - "target": "ES2020", + "target": "ES2021", + "lib": ["ES2021"], "moduleResolution": "nodenext", "declaration": true, "noImplicitAny": false,