mega-framework 0.1.4 → 0.1.6
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/package.json +1 -1
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/mega.config.js +61 -2
- package/sample/crud/package.json +2 -2
- package/sample/crud/yarn.lock +1 -1
- package/src/cli/commands/new.js +115 -67
- package/src/cli/commands/scaffold.js +6 -12
- package/src/cli/index.js +133 -12
- package/src/core/boot.js +30 -1
- package/src/core/config-validator.js +25 -0
- package/src/core/mega-app.js +25 -21
- package/src/core/mega-cluster.js +50 -12
- package/src/core/scope-registry.js +0 -1
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-shutdown.js +51 -13
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
- package/sample/crud/test/apps/main/auth-service.test.js +0 -93
- package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
- package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
- package/sample/crud/test/apps/main/email-job.test.js +0 -76
- package/sample/crud/test/apps/main/guide-service.test.js +0 -68
- package/sample/crud/test/apps/main/hash-task.test.js +0 -30
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
- package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/note-service.test.js +0 -68
- package/sample/crud/test/apps/main/perf-service.test.js +0 -121
- package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
- package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
- package/sample/crud/test/apps/main/user-service.test.js +0 -65
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
- package/templates/project/app.config.tpl +0 -8
- package/templates/project/app.config.views.tpl +0 -37
- package/templates/project/ecosystem.config.tpl +0 -10
- package/templates/project/env.tpl +0 -12
- package/templates/project/gitignore.tpl +0 -8
- package/templates/project/locales/client/en.json.tpl +0 -3
- package/templates/project/locales/client/ko.json.tpl +0 -3
- package/templates/project/locales/server/en.json.tpl +0 -17
- package/templates/project/locales/server/ko.json.tpl +0 -17
- package/templates/project/mega.config.tpl +0 -11
- package/templates/project/package.tpl +0 -25
- package/templates/project/public/css/app.css +0 -101
- package/templates/project/public/js/app.js +0 -54
- package/templates/project/public/js/theme-init.js +0 -12
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
- package/templates/project/readme.tpl +0 -48
- package/templates/project/route.test.tpl +0 -13
- package/templates/project/route.test.views.tpl +0 -15
- package/templates/project/route.tpl +0 -10
- package/templates/project/route.views.tpl +0 -10
- package/templates/project/views/index.ejs.tpl +0 -58
- package/templates/project/views/layout.ejs.tpl +0 -73
- package/templates/project/vitest.config.tpl +0 -8
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* WS 채팅 + ASP 통합 테스트(ADR-158) — 실 sample/crud 부팅(listen) + ASP `ws` 클라이언트.
|
|
4
|
-
*
|
|
5
|
-
* 검증:
|
|
6
|
-
* - WS upgrade 세션 인증(makeWsRequireAuth/readSession) — 비로그인 401, 로그인 통과.
|
|
7
|
-
* - ASP E: 프레임 round-trip — 클라가 envelope `{v,id,type,ts,payload}` 를 wsEncrypt(E:) 로 보내고
|
|
8
|
-
* 서버가 복호→dispatch→E: 로 broadcast. (이 ws 클라의 wire 는 WASM MegaSocket `protocol:'envelope'`
|
|
9
|
-
* 모드와 byte 동일 — frame_encrypt==wsEncrypt, envelope 동일, ADR-160.)
|
|
10
|
-
* - broadcast — 두 로그인 클라가 같은 채널에서 서로의 메시지를 받는다.
|
|
11
|
-
*
|
|
12
|
-
* 인프라(pg·redis·mongo) + ASP_MASTER_SECRET env 가 없으면 통째로 skip.
|
|
13
|
-
*/
|
|
14
|
-
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
15
|
-
import { WebSocket } from 'ws'
|
|
16
|
-
import { bootApp, MegaShutdown, createWsMessage } from 'mega-framework'
|
|
17
|
-
import { MegaAspCrypto } from 'mega-framework/lib'
|
|
18
|
-
import { fileURLToPath } from 'node:url'
|
|
19
|
-
import { dirname, resolve } from 'node:path'
|
|
20
|
-
import { User } from '../../../apps/main/models/user.js'
|
|
21
|
-
|
|
22
|
-
const { wsEncrypt, wsDecrypt } = MegaAspCrypto
|
|
23
|
-
const PROJECT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
|
24
|
-
|
|
25
|
-
const WS_PATH = '/ws/chat'
|
|
26
|
-
const HOSTNAME = 'localhost' // Host → 호스트 라우팅 + ASP domain 유도값(둘 다 'localhost').
|
|
27
|
-
const UA = 'MegaChatTest/1.0' // ASP 키 유도용 — 연결 헤더와 wsEncrypt 가 동일해야 복호 성공.
|
|
28
|
-
const SECRET = process.env.ASP_MASTER_SECRET ?? ''
|
|
29
|
-
|
|
30
|
-
const hasInfra = Boolean(
|
|
31
|
-
process.env.DATABASE_URL &&
|
|
32
|
-
process.env.REDIS_SESSION_URL &&
|
|
33
|
-
process.env.REDIS_RATE_URL &&
|
|
34
|
-
process.env.REDIS_DEMO_URL &&
|
|
35
|
-
process.env.MONGO_URL &&
|
|
36
|
-
process.env.NATS_JOBS_URL && // wsCluster(broadcast/roster) 가 NATS 'jobs' 버스를 쓴다(ADR-176).
|
|
37
|
-
process.env.SESSION_SECRET &&
|
|
38
|
-
SECRET,
|
|
39
|
-
)
|
|
40
|
-
const d = hasInfra ? describe : describe.skip
|
|
41
|
-
|
|
42
|
-
/** set-cookie → 쿠키 jar. @param {any} res @param {Record<string,string>} jar */
|
|
43
|
-
function applyCookies(res, jar) {
|
|
44
|
-
const raw = res.headers['set-cookie']
|
|
45
|
-
const arr = Array.isArray(raw) ? raw : raw ? [raw] : []
|
|
46
|
-
for (const c of arr) {
|
|
47
|
-
const pair = String(c).split(';')[0]
|
|
48
|
-
const eq = pair.indexOf('=')
|
|
49
|
-
if (eq === -1) continue
|
|
50
|
-
const name = pair.slice(0, eq).trim()
|
|
51
|
-
const val = pair.slice(eq + 1).trim()
|
|
52
|
-
if (val === '') delete jar[name]
|
|
53
|
-
else jar[name] = val
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** @param {Record<string,string>} jar @returns {string} */
|
|
58
|
-
const cookieHeader = (jar) => Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
|
|
59
|
-
|
|
60
|
-
/** @param {string} html @returns {string} */
|
|
61
|
-
function csrfFrom(html) {
|
|
62
|
-
const m = /name="_csrf" value="([^"]+)"/.exec(html)
|
|
63
|
-
if (!m) throw new Error('csrf token not found')
|
|
64
|
-
return m[1]
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** @param {Record<string,string>} fields @returns {string} */
|
|
68
|
-
const form = (fields) =>
|
|
69
|
-
Object.entries(fields).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
|
70
|
-
|
|
71
|
-
/** 회원가입(→자동 로그인)으로 세션 쿠키 jar 확보. @param {any} fastify @param {string} email @returns {Promise<Record<string,string>>} */
|
|
72
|
-
async function registerUser(fastify, email) {
|
|
73
|
-
const jar = {}
|
|
74
|
-
const reg = await fastify.inject({ method: 'GET', url: '/register' })
|
|
75
|
-
applyCookies(reg, jar)
|
|
76
|
-
const done = await fastify.inject({
|
|
77
|
-
method: 'POST',
|
|
78
|
-
url: '/register',
|
|
79
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
80
|
-
payload: form({ _csrf: csrfFrom(reg.body), name: `User ${email}`, email, password: 'secret-pass-123' }),
|
|
81
|
-
})
|
|
82
|
-
applyCookies(done, jar)
|
|
83
|
-
return jar
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* ASP envelope ws 클라이언트(WASM `protocol:'envelope'` 모드 byte-equivalent).
|
|
88
|
-
* @param {number} port @param {Record<string,string>} jar
|
|
89
|
-
* @returns {Promise<any>}
|
|
90
|
-
*/
|
|
91
|
-
function openClient(port, jar) {
|
|
92
|
-
const url = `ws://${HOSTNAME}:${port}${WS_PATH}`
|
|
93
|
-
const socket = /** @type {any} */ (
|
|
94
|
-
new WebSocket(url, /** @type {any} */ ({ headers: { 'user-agent': UA, cookie: cookieHeader(jar) } }))
|
|
95
|
-
)
|
|
96
|
-
/** @type {object[]} */ const queue = []
|
|
97
|
-
/** @type {Array<(m: object) => void>} */ const waiters = []
|
|
98
|
-
socket.lastEncrypted = false
|
|
99
|
-
socket.on('message', (/** @type {Buffer} */ data) => {
|
|
100
|
-
const frame = data.toString('utf8')
|
|
101
|
-
socket.lastEncrypted = frame.startsWith('E:')
|
|
102
|
-
const plain = frame.startsWith('E:')
|
|
103
|
-
? wsDecrypt(SECRET, HOSTNAME, WS_PATH, UA, frame.slice(2))
|
|
104
|
-
: frame.startsWith('P:')
|
|
105
|
-
? frame.slice(2)
|
|
106
|
-
: frame
|
|
107
|
-
const msg = JSON.parse(plain)
|
|
108
|
-
const w = waiters.shift()
|
|
109
|
-
if (w) w(msg)
|
|
110
|
-
else queue.push(msg)
|
|
111
|
-
})
|
|
112
|
-
/** 다음 메시지 1건(평문 envelope). */
|
|
113
|
-
socket.next = () =>
|
|
114
|
-
new Promise((res) => {
|
|
115
|
-
const m = queue.shift()
|
|
116
|
-
if (m !== undefined) res(m)
|
|
117
|
-
else waiters.push(res)
|
|
118
|
-
})
|
|
119
|
-
/** type 이 t 인 다음 메시지까지 소비. @param {string} t */
|
|
120
|
-
socket.nextOf = async (t) => {
|
|
121
|
-
for (let i = 0; i < 20; i++) {
|
|
122
|
-
const m = await socket.next()
|
|
123
|
-
if (m.type === t) return m
|
|
124
|
-
}
|
|
125
|
-
throw new Error(`message type '${t}' not received`)
|
|
126
|
-
}
|
|
127
|
-
/** E: 암호 envelope 송신. @param {string} type @param {object} payload */
|
|
128
|
-
socket.sendChat = (type, payload) => {
|
|
129
|
-
const env = createWsMessage({ type, payload })
|
|
130
|
-
socket.send(`E:${wsEncrypt(SECRET, HOSTNAME, WS_PATH, UA, JSON.stringify(env))}`)
|
|
131
|
-
}
|
|
132
|
-
return new Promise((res, rej) => {
|
|
133
|
-
socket.once('open', () => res(socket))
|
|
134
|
-
socket.once('error', rej)
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
d('WS 채팅 + ASP E2E — sample/crud 실 인프라 (ADR-158)', () => {
|
|
139
|
-
/** @type {Awaited<ReturnType<typeof bootApp>>} */ let boot
|
|
140
|
-
/** @type {any} */ let fastify
|
|
141
|
-
/** @type {number} */ let port
|
|
142
|
-
/** @type {Record<string,string>} */ let jarA
|
|
143
|
-
/** @type {Record<string,string>} */ let jarB
|
|
144
|
-
const emailA = `ws-a-${Date.now()}@example.com`
|
|
145
|
-
const emailB = `ws-b-${Date.now()}@example.com`
|
|
146
|
-
|
|
147
|
-
beforeAll(async () => {
|
|
148
|
-
MegaShutdown._reset()
|
|
149
|
-
boot = await bootApp(PROJECT, { listen: true, port: 0, host: '127.0.0.1' })
|
|
150
|
-
const app = boot.megaApps.find((a) => a.name === 'main')
|
|
151
|
-
fastify = app?.fastify
|
|
152
|
-
port = /** @type {any} */ (boot.server)._httpServer.address().port
|
|
153
|
-
await User.query(
|
|
154
|
-
'CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now())',
|
|
155
|
-
)
|
|
156
|
-
await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
|
|
157
|
-
await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
|
|
158
|
-
// roster 는 이제 프레임워크(wsCluster, NATS)가 인메모리로 관리한다 — 새 부팅이라 빈 명단에서 시작(별도 정리 불필요).
|
|
159
|
-
jarA = await registerUser(fastify, emailA)
|
|
160
|
-
jarB = await registerUser(fastify, emailB)
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
afterAll(async () => {
|
|
164
|
-
if (!boot) return
|
|
165
|
-
const app = boot.megaApps.find((a) => a.name === 'main')
|
|
166
|
-
// wsCluster(NATS 구독·타이머) 정리 — 이벤트루프 누수 방지(framework-internal stop).
|
|
167
|
-
await /** @type {any} */ (app)?._wsCluster?.stop().catch(() => {})
|
|
168
|
-
await User.query('DELETE FROM users WHERE email = ANY($1)', [[emailA, emailB]]).catch(() => {})
|
|
169
|
-
await boot.server.close().catch(() => {})
|
|
170
|
-
await app?.sessionStore?.disconnect().catch(() => {})
|
|
171
|
-
await boot.ctx.cache('rate').disconnect().catch(() => {})
|
|
172
|
-
await boot.ctx.cache('demo').disconnect().catch(() => {})
|
|
173
|
-
await boot.ctx.bus('jobs').disconnect().catch(() => {}) // wsCluster 가 쓰던 NATS 버스.
|
|
174
|
-
await boot.ctx.db('mongo').disconnect().catch(() => {})
|
|
175
|
-
await boot.ctx.db('primary').disconnect().catch(() => {})
|
|
176
|
-
MegaShutdown._reset()
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
test('비로그인 WS upgrade 는 401 로 거부된다', async () => {
|
|
180
|
-
const url = `ws://${HOSTNAME}:${port}${WS_PATH}`
|
|
181
|
-
const status = await new Promise((res, rej) => {
|
|
182
|
-
const s = new WebSocket(url, /** @type {any} */ ({ headers: { 'user-agent': UA } }))
|
|
183
|
-
s.on('unexpected-response', (_req, response) => {
|
|
184
|
-
res(response.statusCode)
|
|
185
|
-
s.terminate()
|
|
186
|
-
})
|
|
187
|
-
s.on('open', () => {
|
|
188
|
-
s.close()
|
|
189
|
-
rej(new Error('로그인 없이 연결되면 안 됨'))
|
|
190
|
-
})
|
|
191
|
-
s.on('error', () => {}) // unexpected-response 뒤 따라오는 소켓 에러는 무시.
|
|
192
|
-
})
|
|
193
|
-
expect(status).toBe(401)
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
test('로그인 클라는 연결 직후 ASP E: 로 chat.history 를 받는다', async () => {
|
|
197
|
-
const a = await openClient(port, jarA)
|
|
198
|
-
try {
|
|
199
|
-
const history = await a.nextOf('chat.history')
|
|
200
|
-
expect(a.lastEncrypted).toBe(true) // 수신 프레임이 E:(암호화) 였음.
|
|
201
|
-
expect(history.payload.me.userId).toBeTruthy()
|
|
202
|
-
expect(Array.isArray(history.payload.items)).toBe(true)
|
|
203
|
-
expect(history.payload.online).toBeGreaterThanOrEqual(1)
|
|
204
|
-
// roster(cluster-wide, 프레임워크 NATS 동기화) 에 내 이름이 있고, 워커 PID 가 실린다.
|
|
205
|
-
expect(history.payload.members).toContain(history.payload.me.userName)
|
|
206
|
-
expect(typeof history.payload.workerPid).toBe('number')
|
|
207
|
-
} finally {
|
|
208
|
-
a.close()
|
|
209
|
-
}
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
test('broadcast — A 가 보낸 메시지를 A(echo)와 B 가 모두 ASP E: 로 받는다', async () => {
|
|
213
|
-
const b = await openClient(port, jarB)
|
|
214
|
-
await b.nextOf('chat.history')
|
|
215
|
-
const a = await openClient(port, jarA)
|
|
216
|
-
await a.nextOf('chat.history')
|
|
217
|
-
// A 입장 → B 는 presence(join) 수신.
|
|
218
|
-
const join = await b.nextOf('chat.presence')
|
|
219
|
-
expect(join.payload.event).toBe('join')
|
|
220
|
-
|
|
221
|
-
const text = `hello-${Date.now()}`
|
|
222
|
-
a.sendChat('chat.send', { text })
|
|
223
|
-
|
|
224
|
-
const onA = await a.nextOf('chat.msg')
|
|
225
|
-
const onB = await b.nextOf('chat.msg')
|
|
226
|
-
expect(onA.payload.text).toBe(text)
|
|
227
|
-
expect(onB.payload.text).toBe(text)
|
|
228
|
-
expect(a.lastEncrypted && b.lastEncrypted).toBe(true) // 양쪽 모두 E: 암호 수신.
|
|
229
|
-
|
|
230
|
-
a.close()
|
|
231
|
-
b.close()
|
|
232
|
-
})
|
|
233
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* apps/main/app.config.js — App-only + Shared-Reference 스코프(ADR-061). 기본 앱 `main`.
|
|
4
|
-
* EJS + ejs-mate 서버사이드 템플릿(ADR-011/136) + i18next 다국어(ADR-037/038/039) +
|
|
5
|
-
* @fastify/static 정적 자산(ADR-071) + Bootstrap 5 뷰(ADR-151)를 함께 켠 골격.
|
|
6
|
-
*/
|
|
7
|
-
export default {
|
|
8
|
-
name: 'main',
|
|
9
|
-
hosts: ['localhost', 'main.localhost'],
|
|
10
|
-
|
|
11
|
-
// EJS + ejs-mate — `views.dir` 기준 레이아웃/파셜 lookup. 렌더 시 req.t/req.lang 자동 병합.
|
|
12
|
-
// views 옵트인이라 HTML 폼 제출(urlencoded)도 자동 파싱된다(ADR-151, @fastify/formbody).
|
|
13
|
-
views: {
|
|
14
|
-
dir: 'apps/main/views',
|
|
15
|
-
layoutDir: 'layouts',
|
|
16
|
-
partialsDir: 'partials',
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
// i18next 다국어 — locale 은 쿠키(`mega.lang`)로만 결정(ADR-038). `<localesDir>/<scope>/<lng>.json`
|
|
20
|
-
// 레이아웃(scope=server/client, ADR-039). `ctx.t()`/뷰 `t()` 는 server scope. navbar 의 언어 메뉴가
|
|
21
|
-
// 쿠키를 굽고 새로고침한다.
|
|
22
|
-
i18n: {
|
|
23
|
-
default: 'ko',
|
|
24
|
-
available: ['ko', 'en'],
|
|
25
|
-
fallback: 'en',
|
|
26
|
-
localesDir: 'apps/main/locales',
|
|
27
|
-
exposeTranslations: true,
|
|
28
|
-
},
|
|
29
|
-
|
|
30
|
-
// 정적 자산 — `${prefix}/<파일>` 로 디스크 서빙(prefix 디폴트 `/static`). Bootstrap 5 vendored +
|
|
31
|
-
// 브랜드 CSS/JS 가 `apps/main/public/` 아래에 있다. dotfiles 기본 차단.
|
|
32
|
-
staticAssets: {
|
|
33
|
-
enabled: true,
|
|
34
|
-
dir: 'apps/main/public',
|
|
35
|
-
prefix: '/static',
|
|
36
|
-
},
|
|
37
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// PM2 ecosystem — server / scheduler / worker / ws-hub 프로세스 정의.
|
|
2
|
-
// 사용: pm2 start ecosystem.config.cjs (PM2 는 별도 설치: npm i -g pm2)
|
|
3
|
-
module.exports = {
|
|
4
|
-
apps: [
|
|
5
|
-
{ name: '{{name}}-server', script: 'node_modules/.bin/mega', args: 'start', instances: 1, autorestart: true },
|
|
6
|
-
{ name: '{{name}}-scheduler', script: 'node_modules/.bin/mega', args: 'scheduler', instances: 1, autorestart: true },
|
|
7
|
-
{ name: '{{name}}-worker', script: 'node_modules/.bin/mega', args: 'worker', instances: 2, autorestart: true },
|
|
8
|
-
{ name: '{{name}}-ws-hub', script: 'node_modules/.bin/mega-ws-hub', instances: 1, autorestart: true },
|
|
9
|
-
],
|
|
10
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# {{name}} 환경변수 (.env.example) — 복사해서 .env 로 쓰고 실제 값 채우기. .env 는 git 에 안 올림.
|
|
2
|
-
|
|
3
|
-
# HTTP 포트
|
|
4
|
-
PORT=3000
|
|
5
|
-
|
|
6
|
-
# 세션 서명 시크릿(세션 사용 시 필수) — 32바이트 이상 랜덤
|
|
7
|
-
# SESSION_SECRET=change-me
|
|
8
|
-
|
|
9
|
-
# 데이터베이스/캐시/버스 연결 (어댑터 사용 시)
|
|
10
|
-
# DATABASE_URL=postgres://user:pass@localhost:5432/{{name}}
|
|
11
|
-
# REDIS_URL=redis://localhost:6379
|
|
12
|
-
# NATS_URL=nats://localhost:4222
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"welcome": "Welcome",
|
|
3
|
-
"nav_home": "Home",
|
|
4
|
-
"theme_toggle": "Toggle theme",
|
|
5
|
-
"footer_built": "Built with MEGA-FRAMEWORK · Bootstrap 5",
|
|
6
|
-
"hero_title": "Welcome to {{name}}",
|
|
7
|
-
"hero_subtitle": "A project built with MEGA-FRAMEWORK. EJS + ejs-mate server-side views, cookie-based i18n, and a Bootstrap 5 design come built in.",
|
|
8
|
-
"hero_cta_primary": "Framework docs",
|
|
9
|
-
"hero_cta_secondary": "Bootstrap docs",
|
|
10
|
-
"features_heading": "Built in",
|
|
11
|
-
"feature1_title": "Polished views",
|
|
12
|
-
"feature1_desc": "A Bootstrap 5 navbar, hero, and card grid make up the first screen.",
|
|
13
|
-
"feature2_title": "Language toggle",
|
|
14
|
-
"feature2_desc": "Switch Korean / English instantly from the navbar menu (cookie-based).",
|
|
15
|
-
"feature3_title": "Light & dark mode",
|
|
16
|
-
"feature3_desc": "Bootstrap 5.3 color modes toggle the theme and remember it in the browser."
|
|
17
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"welcome": "환영합니다",
|
|
3
|
-
"nav_home": "홈",
|
|
4
|
-
"theme_toggle": "테마 전환",
|
|
5
|
-
"footer_built": "MEGA-FRAMEWORK 로 제작 · Bootstrap 5",
|
|
6
|
-
"hero_title": "{{name}} 에 오신 걸 환영합니다",
|
|
7
|
-
"hero_subtitle": "MEGA-FRAMEWORK 로 만든 프로젝트입니다. EJS + ejs-mate 서버사이드 뷰, 쿠키 기반 다국어, Bootstrap 5 디자인이 기본 탑재되어 있습니다.",
|
|
8
|
-
"hero_cta_primary": "프레임워크 문서",
|
|
9
|
-
"hero_cta_secondary": "Bootstrap 문서",
|
|
10
|
-
"features_heading": "기본 탑재 기능",
|
|
11
|
-
"feature1_title": "세련된 뷰",
|
|
12
|
-
"feature1_desc": "Bootstrap 5 navbar · hero · 카드 그리드로 첫 화면이 구성됩니다.",
|
|
13
|
-
"feature2_title": "다국어 토글",
|
|
14
|
-
"feature2_desc": "navbar 의 언어 메뉴로 한국어/English 를 즉시 전환합니다(쿠키 기반).",
|
|
15
|
-
"feature3_title": "라이트·다크 모드",
|
|
16
|
-
"feature3_desc": "Bootstrap 5.3 color modes 로 테마를 전환하고 브라우저에 저장합니다."
|
|
17
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* mega.config.js — Global-only 스코프(ADR-061). 활성 앱 whitelist·전역 자원(databases/caches/buses)·
|
|
4
|
-
* 서버 설정만 둔다. App-only 키(cors/helmet 등)는 apps/<name>/app.config.js 로.
|
|
5
|
-
*/
|
|
6
|
-
export default {
|
|
7
|
-
apps: ['main'],
|
|
8
|
-
server: {
|
|
9
|
-
port: Number(process.env.PORT ?? 3000),
|
|
10
|
-
},
|
|
11
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "{{name}}",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"engines": {
|
|
7
|
-
"node": ">=20"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"dev": "mega start",
|
|
11
|
-
"start": "mega start",
|
|
12
|
-
"scheduler": "mega scheduler",
|
|
13
|
-
"worker": "mega worker",
|
|
14
|
-
"ws-hub": "mega-ws-hub",
|
|
15
|
-
"dev:all": "concurrently -n server,scheduler,worker -c blue,green,magenta \"mega start\" \"mega scheduler\" \"mega worker\"",
|
|
16
|
-
"test": "mega test"
|
|
17
|
-
},
|
|
18
|
-
"dependencies": {
|
|
19
|
-
"mega-framework": "^0.1.0"
|
|
20
|
-
},
|
|
21
|
-
"devDependencies": {
|
|
22
|
-
"concurrently": "^9.0.0",
|
|
23
|
-
"vitest": "^4.0.0"
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* app.css — MEGA-FRAMEWORK 브랜드 소폭 커스텀(Bootstrap 5.3 위에 얹는다).
|
|
3
|
-
* Bootstrap 컴포넌트는 인스턴스 CSS 변수(--bs-btn-*, --bs-link-* 등)로 색을 잡으므로
|
|
4
|
-
* Sass 재컴파일 없이 변수만 덮어써 브랜드 색을 입힌다. per Bootstrap 5.3 CSS variables 문서.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
:root {
|
|
8
|
-
--brand: #5b4bff;
|
|
9
|
-
--brand-rgb: 91, 75, 255;
|
|
10
|
-
--brand-dark: #4a3fd6;
|
|
11
|
-
--brand-light: #efedff;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/* 링크·primary 강조를 브랜드 색으로(라이트/다크 공통) */
|
|
15
|
-
a {
|
|
16
|
-
--bs-link-color-rgb: var(--brand-rgb);
|
|
17
|
-
--bs-link-hover-color-rgb: 74, 63, 214;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
.btn-primary {
|
|
21
|
-
--bs-btn-bg: var(--brand);
|
|
22
|
-
--bs-btn-border-color: var(--brand);
|
|
23
|
-
--bs-btn-hover-bg: var(--brand-dark);
|
|
24
|
-
--bs-btn-hover-border-color: var(--brand-dark);
|
|
25
|
-
--bs-btn-active-bg: var(--brand-dark);
|
|
26
|
-
--bs-btn-active-border-color: var(--brand-dark);
|
|
27
|
-
--bs-btn-disabled-bg: var(--brand);
|
|
28
|
-
--bs-btn-disabled-border-color: var(--brand);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
.btn-outline-primary {
|
|
32
|
-
--bs-btn-color: var(--brand);
|
|
33
|
-
--bs-btn-border-color: var(--brand);
|
|
34
|
-
--bs-btn-hover-bg: var(--brand);
|
|
35
|
-
--bs-btn-hover-border-color: var(--brand);
|
|
36
|
-
--bs-btn-active-bg: var(--brand);
|
|
37
|
-
--bs-btn-active-border-color: var(--brand);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
.text-brand {
|
|
41
|
-
color: var(--brand) !important;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
.navbar-brand {
|
|
45
|
-
font-weight: 700;
|
|
46
|
-
letter-spacing: -0.02em;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
.navbar-brand .brand-dot {
|
|
50
|
-
color: var(--brand);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/* hero — 브랜드 그라데이션 배경 + 넉넉한 여백 */
|
|
54
|
-
.hero {
|
|
55
|
-
background: radial-gradient(120% 120% at 0% 0%, rgba(var(--brand-rgb), 0.12), transparent 60%),
|
|
56
|
-
radial-gradient(120% 120% at 100% 0%, rgba(var(--brand-rgb), 0.08), transparent 55%);
|
|
57
|
-
border-radius: 1rem;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
.hero h1 {
|
|
61
|
-
letter-spacing: -0.03em;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/* feature 카드 — hover 살짝 떠오르는 인터랙션 */
|
|
65
|
-
.feature-card {
|
|
66
|
-
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
.feature-card:hover {
|
|
70
|
-
transform: translateY(-3px);
|
|
71
|
-
box-shadow: 0 0.75rem 1.5rem rgba(var(--brand-rgb), 0.12);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
.feature-icon {
|
|
75
|
-
display: inline-flex;
|
|
76
|
-
align-items: center;
|
|
77
|
-
justify-content: center;
|
|
78
|
-
width: 3rem;
|
|
79
|
-
height: 3rem;
|
|
80
|
-
border-radius: 0.75rem;
|
|
81
|
-
background: var(--brand-light);
|
|
82
|
-
color: var(--brand);
|
|
83
|
-
font-size: 1.5rem;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
[data-bs-theme='dark'] .feature-icon {
|
|
87
|
-
background: rgba(var(--brand-rgb), 0.18);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
footer.site-footer {
|
|
91
|
-
border-top: 1px solid var(--bs-border-color);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/* 테마 토글 아이콘 — 현재 테마에서 "전환 대상"을 보여준다(라이트면 🌙, 다크면 ☀️). */
|
|
95
|
-
[data-bs-theme='light'] .theme-icon-light {
|
|
96
|
-
display: none;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
[data-bs-theme='dark'] .theme-icon-dark {
|
|
100
|
-
display: none;
|
|
101
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* app.js — 다크모드 토글 + 언어 토글 + 삭제 확인 모달 wiring(클라이언트 동작).
|
|
3
|
-
*
|
|
4
|
-
* helmet CSP(script-src 'self')가 인라인 스크립트를 막으므로 모든 클라 동작은 외부 파일로 둔다(ADR-153).
|
|
5
|
-
* 다크모드: 페인트 전 적용은 theme-init.js(<head>), 토글은 여기서. localStorage('mega.theme') 저장.
|
|
6
|
-
* 언어: 프레임워크가 locale 을 쿠키(mega.lang)로만 감지하므로(ADR-038), 토글은 쿠키를 굽고 새로고침.
|
|
7
|
-
* 삭제모달: 트리거 버튼의 data-action/data-name 으로 form action·문구를 채운다(#deleteModal 있을 때만).
|
|
8
|
-
*/
|
|
9
|
-
;(function () {
|
|
10
|
-
'use strict'
|
|
11
|
-
|
|
12
|
-
/** @param {string} theme */
|
|
13
|
-
function applyTheme(theme) {
|
|
14
|
-
document.documentElement.setAttribute('data-bs-theme', theme)
|
|
15
|
-
try {
|
|
16
|
-
localStorage.setItem('mega.theme', theme)
|
|
17
|
-
} catch (e) {
|
|
18
|
-
// localStorage 차단(사생활 모드 등)은 비치명적 — 이번 페이지에서만 테마가 안 남을 뿐.
|
|
19
|
-
console.debug('theme persist skipped:', e && e.message)
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
|
|
24
|
-
btn.addEventListener('click', function () {
|
|
25
|
-
var current = document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light'
|
|
26
|
-
applyTheme(current === 'dark' ? 'light' : 'dark')
|
|
27
|
-
})
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
document.querySelectorAll('[data-lang]').forEach(function (el) {
|
|
31
|
-
el.addEventListener('click', function (e) {
|
|
32
|
-
e.preventDefault()
|
|
33
|
-
var lang = el.getAttribute('data-lang')
|
|
34
|
-
// 1년 유지, 같은 사이트 요청에만 전송(samesite=lax).
|
|
35
|
-
document.cookie = 'mega.lang=' + encodeURIComponent(lang) + '; path=/; max-age=31536000; samesite=lax'
|
|
36
|
-
location.reload()
|
|
37
|
-
})
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
// 삭제 확인 모달 — 클릭된 행 버튼의 data-action(삭제 URL)·data-name 으로 form·문구를 채운다.
|
|
41
|
-
// #deleteModal 이 있는 페이지(예: 목록)에서만 동작하고, 없으면 no-op.
|
|
42
|
-
var deleteModal = document.getElementById('deleteModal')
|
|
43
|
-
if (deleteModal) {
|
|
44
|
-
deleteModal.addEventListener('show.bs.modal', function (event) {
|
|
45
|
-
var btn = event.relatedTarget
|
|
46
|
-
if (!btn) return
|
|
47
|
-
var form = document.getElementById('deleteForm')
|
|
48
|
-
var action = btn.getAttribute('data-action')
|
|
49
|
-
if (form && action) form.setAttribute('action', action)
|
|
50
|
-
var nameEl = document.getElementById('deleteModalName')
|
|
51
|
-
if (nameEl) nameEl.textContent = btn.getAttribute('data-name') || ''
|
|
52
|
-
})
|
|
53
|
-
}
|
|
54
|
-
})()
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* theme-init.js — 저장된 테마를 페인트 전에 적용(FOUC 방지). layout <head> 에서 동기 로드한다.
|
|
3
|
-
* helmet CSP(script-src 'self')가 인라인 스크립트를 막으므로 외부 파일로 둔다(ADR-153).
|
|
4
|
-
*/
|
|
5
|
-
;(function () {
|
|
6
|
-
try {
|
|
7
|
-
var t = localStorage.getItem('mega.theme')
|
|
8
|
-
if (t) document.documentElement.setAttribute('data-bs-theme', t)
|
|
9
|
-
} catch (e) {
|
|
10
|
-
// localStorage 차단(사생활 모드 등)은 비치명적 — 기본 테마로 렌더된다.
|
|
11
|
-
}
|
|
12
|
-
})()
|