@fzgame/cli 0.0.0-stage → 0.1.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/src/dev.ts ADDED
@@ -0,0 +1,311 @@
1
+ import { createReadStream } from 'node:fs'
2
+ import { readFile, stat } from 'node:fs/promises'
3
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
4
+ import { extname, join, resolve, sep } from 'node:path'
5
+ import { KIT_HINT, resolveKitFile } from './kit-path'
6
+ import type { FzGameManifest } from './manifest'
7
+ import { loadManifest } from './pack'
8
+
9
+ const DEFAULT_HOST = '127.0.0.1'
10
+
11
+ function applyCors(res: ServerResponse) {
12
+ res.setHeader('access-control-allow-origin', '*')
13
+ res.setHeader('access-control-allow-headers', '*')
14
+ res.setHeader('access-control-allow-methods', 'GET,POST,PUT,OPTIONS')
15
+ }
16
+
17
+ const MIME: Record<string, string> = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.js': 'application/javascript; charset=utf-8',
20
+ '.mjs': 'application/javascript; charset=utf-8',
21
+ '.css': 'text/css; charset=utf-8',
22
+ '.json': 'application/json; charset=utf-8',
23
+ '.png': 'image/png',
24
+ '.svg': 'image/svg+xml',
25
+ }
26
+
27
+ type Session = {
28
+ id: string
29
+ token: string
30
+ status: string
31
+ score: number | null
32
+ result: unknown
33
+ metadata: { snapshot?: unknown }
34
+ gameInstanceId: string
35
+ }
36
+
37
+ export type DevServerOptions = {
38
+ /** Bind address. Defaults to loopback: the mock runtime has no auth. */
39
+ host?: string
40
+ }
41
+
42
+ export async function startDevServer(
43
+ root: string,
44
+ port: number,
45
+ options: DevServerOptions = {},
46
+ ) {
47
+ const host = options.host || DEFAULT_HOST
48
+ const manifest = await loadManifest(root)
49
+ const sessions = new Map<string, Session>()
50
+
51
+ const server = createServer(async (req, res) => {
52
+ applyCors(res)
53
+ applySecurityHeaders(res)
54
+ if (req.method === 'OPTIONS') {
55
+ res.writeHead(204)
56
+ res.end()
57
+ return
58
+ }
59
+ try {
60
+ const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
61
+ if (url.pathname === '/health') {
62
+ res.writeHead(200, { 'content-type': 'application/json' })
63
+ res.end(JSON.stringify({ ok: true, name: manifest.name, port }))
64
+ return
65
+ }
66
+ if (
67
+ url.pathname.startsWith('/games/runtime') ||
68
+ url.pathname.startsWith('/pub/game-instances')
69
+ ) {
70
+ await handleRuntime(req, res, url, sessions, manifest)
71
+ return
72
+ }
73
+ if (url.pathname === '/@fzgame/kit.js') {
74
+ await serveKit(res)
75
+ return
76
+ }
77
+ if (url.pathname === '/' || url.pathname === '/index.html') {
78
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
79
+ res.end(launcherHtml(requestOrigin(req, host, port), manifest.name))
80
+ return
81
+ }
82
+ await serveStatic(root, url.pathname, res)
83
+ } catch (error) {
84
+ res.writeHead(500, { 'content-type': 'application/json' })
85
+ res.end(JSON.stringify({ message: (error as Error).message }))
86
+ }
87
+ })
88
+
89
+ await new Promise<void>((resolveReady, reject) => {
90
+ server.once('error', (error: NodeJS.ErrnoException) => {
91
+ reject(
92
+ error.code === 'EADDRINUSE'
93
+ ? new Error(`Port ${port} is already in use. Pass --port to pick another one.`)
94
+ : new Error(`Cannot serve on ${host}:${port} (${error.code || error.message}).`),
95
+ )
96
+ })
97
+ server.listen(port, host, () => resolveReady())
98
+ })
99
+ const address = server.address()
100
+ return {
101
+ server,
102
+ host,
103
+ port: typeof address === 'object' && address ? address.port : port,
104
+ manifest,
105
+ }
106
+ }
107
+
108
+ function applySecurityHeaders(res: ServerResponse) {
109
+ // Allow cross-origin requests
110
+ res.setHeader('Access-Control-Allow-Origin', '*')
111
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE')
112
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
113
+
114
+ // Any parent may embed the game, so the portal origin works without a proxy.
115
+ res.setHeader('Content-Security-Policy', 'frame-ancestors *')
116
+
117
+ // Remove restrictive framing header if set by default
118
+ res.removeHeader('X-Frame-Options')
119
+ }
120
+
121
+ async function serveKit(res: ServerResponse) {
122
+ try {
123
+ const body = await readFile(resolveKitFile())
124
+ res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8' })
125
+ res.end(body)
126
+ } catch (error) {
127
+ res.writeHead(404, { 'content-type': 'text/plain' })
128
+ res.end(`Cannot serve @fzgame/kit: ${(error as Error).message}\n${KIT_HINT}`)
129
+ }
130
+ }
131
+
132
+ async function serveStatic(root: string, pathname: string, res: ServerResponse) {
133
+ const rel = decodeURIComponent(pathname).replace(/^\//, '') || 'index.html'
134
+ const abs = resolve(root, rel)
135
+ const rootAbs = resolve(root)
136
+ if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) {
137
+ res.writeHead(403)
138
+ res.end('Forbidden')
139
+ return
140
+ }
141
+ try {
142
+ const info = await stat(abs)
143
+ const file = info.isDirectory() ? join(abs, 'index.html') : abs
144
+ res.writeHead(200, {
145
+ 'content-type': MIME[extname(file)] || 'application/octet-stream',
146
+ })
147
+ createReadStream(file).pipe(res)
148
+ } catch {
149
+ res.writeHead(404)
150
+ res.end('Not found')
151
+ }
152
+ }
153
+
154
+ async function handleRuntime(
155
+ req: IncomingMessage,
156
+ res: ServerResponse,
157
+ url: URL,
158
+ sessions: Map<string, Session>,
159
+ manifest: FzGameManifest,
160
+ ) {
161
+ res.setHeader('content-type', 'application/json')
162
+ res.setHeader('access-control-allow-origin', '*')
163
+ res.setHeader('access-control-allow-headers', 'content-type')
164
+ res.setHeader('access-control-allow-methods', 'GET,POST,PUT,OPTIONS')
165
+ if (req.method === 'OPTIONS') {
166
+ res.writeHead(204)
167
+ res.end()
168
+ return
169
+ }
170
+
171
+ if (
172
+ req.method === 'GET' &&
173
+ (url.pathname.startsWith('/pub/game-instances/') ||
174
+ url.pathname.startsWith('/games/runtime/instances/'))
175
+ ) {
176
+ res.end(
177
+ JSON.stringify({
178
+ id: url.pathname.split('/').pop(),
179
+ name: manifest.name,
180
+ status: 'PUBLISHED',
181
+ overrides: {},
182
+ game: { name: manifest.name, configSchema: manifest.configSchema ?? {} },
183
+ }),
184
+ )
185
+ return
186
+ }
187
+
188
+ if (req.method === 'POST' && url.pathname === '/games/runtime/sessions') {
189
+ const body = await readJson(req)
190
+ const existing = [...sessions.values()].find(
191
+ (s) => s.gameInstanceId === body.gameInstanceId && s.status !== 'FINISHED',
192
+ )
193
+ if (existing) {
194
+ res.end(JSON.stringify(existing))
195
+ return
196
+ }
197
+ const session: Session = {
198
+ id: `dev-${sessions.size + 1}`,
199
+ token: `dev-token-${sessions.size + 1}`,
200
+ status: 'CREATED',
201
+ score: null,
202
+ result: null,
203
+ metadata: { snapshot: {} },
204
+ gameInstanceId: String(body.gameInstanceId || 'dev-instance'),
205
+ }
206
+ sessions.set(session.token, session)
207
+ res.end(JSON.stringify(session))
208
+ return
209
+ }
210
+
211
+ const emitMatch = url.pathname.match(/^\/games\/runtime\/sessions\/([^/]+)\/emit$/)
212
+ if (req.method === 'POST' && emitMatch) {
213
+ const session = sessions.get(emitMatch[1]!)
214
+ if (!session) {
215
+ res.writeHead(404)
216
+ res.end(JSON.stringify({ message: 'Session not found' }))
217
+ return
218
+ }
219
+ const body = await readJson(req)
220
+ applyDevEmit(
221
+ session,
222
+ String(body.type || ''),
223
+ body.payload as Record<string, unknown> | undefined,
224
+ )
225
+ res.end(JSON.stringify(session))
226
+ return
227
+ }
228
+
229
+ const snapMatch = url.pathname.match(/^\/games\/runtime\/sessions\/([^/]+)\/snapshot$/)
230
+ if (req.method === 'PUT' && snapMatch) {
231
+ const session = sessions.get(snapMatch[1]!)
232
+ if (!session) {
233
+ res.writeHead(404)
234
+ res.end(JSON.stringify({ message: 'Session not found' }))
235
+ return
236
+ }
237
+ const body = await readJson(req)
238
+ session.metadata.snapshot = body.state ?? body
239
+ res.end(JSON.stringify(session))
240
+ return
241
+ }
242
+
243
+ const getMatch = url.pathname.match(/^\/games\/runtime\/sessions\/([^/]+)$/)
244
+ if (req.method === 'GET' && getMatch) {
245
+ const session = sessions.get(getMatch[1]!)
246
+ if (!session) {
247
+ res.writeHead(404)
248
+ res.end(JSON.stringify({ message: 'Session not found' }))
249
+ return
250
+ }
251
+ res.end(JSON.stringify(session))
252
+ return
253
+ }
254
+
255
+ res.writeHead(404)
256
+ res.end(JSON.stringify({ message: 'Not found' }))
257
+ }
258
+
259
+ function applyDevEmit(session: Session, type: string, payload?: Record<string, unknown>) {
260
+ if (type === 'ready') session.status = 'READY'
261
+ if (type === 'start') session.status = 'PLAYING'
262
+ if (type === 'pause') session.status = 'PAUSED'
263
+ if (type === 'finish') {
264
+ session.status = 'FINISHED'
265
+ if (typeof payload?.score === 'number') session.score = payload.score
266
+ if (payload?.result) session.result = payload.result
267
+ }
268
+ if (type === 'abort') session.status = 'ABORTED'
269
+ if (type === 'score' && typeof payload?.score === 'number') session.score = payload.score
270
+ if (type === 'snapshot') session.metadata.snapshot = payload?.state ?? payload
271
+ }
272
+
273
+ function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
274
+ return new Promise((resolveJson, reject) => {
275
+ const chunks: Buffer[] = []
276
+ req.on('data', (c) => chunks.push(c as Buffer))
277
+ req.on('end', () => {
278
+ if (chunks.length === 0) return resolveJson({})
279
+ try {
280
+ resolveJson(JSON.parse(Buffer.concat(chunks).toString('utf8')))
281
+ } catch (error) {
282
+ reject(error)
283
+ }
284
+ })
285
+ req.on('error', reject)
286
+ })
287
+ }
288
+
289
+ function requestOrigin(req: IncomingMessage, host: string, port: number) {
290
+ if (req.headers.host) return `http://${req.headers.host}`
291
+ const shown = host === '0.0.0.0' || host === '::' ? DEFAULT_HOST : host
292
+ return `http://${shown}:${port}`
293
+ }
294
+
295
+ function launcherHtml(origin: string, name: string) {
296
+ const kit = `kit=${encodeURIComponent('/@fzgame/kit.js')}`
297
+ const play = `/game/index.html?gameInstanceId=dev-instance&playerToken=dev-player&apiUrl=${encodeURIComponent(origin)}&${kit}`
298
+ const preview = `/game/index.html?preview=true&${kit}`
299
+ const editor = `/editor/index.html?gameInstanceId=dev-instance&apiUrl=${encodeURIComponent(origin)}&${kit}`
300
+ return `<!doctype html>
301
+ <html>
302
+ <head><meta charset="utf-8"><title>${name} — fzgame dev</title></head>
303
+ <body style="font-family:system-ui;padding:24px">
304
+ <h1>${name}</h1>
305
+ <p>Local game developer mode.</p>
306
+ <p><a href="${play}">Open game</a></p>
307
+ <p><a href="${preview}">Open preview</a> (no play session; pass <code>gameSetting</code> JSON to try config)</p>
308
+ <p><a href="${editor}">Open editor</a></p>
309
+ </body>
310
+ </html>`
311
+ }
package/src/env.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Load KEY=VALUE pairs from `<dir>/.env` into process.env.
6
+ *
7
+ * Variables already set in the environment win, so a shell export or a CI
8
+ * secret is never overridden by a checked-in file. Missing file is not an error.
9
+ */
10
+ export function loadEnvFile(dir: string = process.cwd()): boolean {
11
+ let raw: string
12
+ try {
13
+ raw = readFileSync(join(dir, '.env'), 'utf8')
14
+ } catch {
15
+ return false
16
+ }
17
+
18
+ for (const line of raw.split('\n')) {
19
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line)
20
+ if (!match) continue
21
+ const key = match[1]!
22
+ let value = match[2]!
23
+ const quoted =
24
+ (value.startsWith('"') && value.endsWith('"')) ||
25
+ (value.startsWith("'") && value.endsWith("'"))
26
+ if (quoted) value = value.slice(1, -1)
27
+ if (process.env[key] === undefined) process.env[key] = value
28
+ }
29
+ return true
30
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,37 @@
1
+ const DEFAULT_TIMEOUT_MS = 30_000
2
+
3
+ export type RequestOptions = RequestInit & { timeoutMs?: number }
4
+
5
+ /**
6
+ * fetch with a hard deadline and an error message that names the URL.
7
+ *
8
+ * undici's defaults bound connection and idle time, so a wedged API or proxy
9
+ * can otherwise hold a CLI or a runner forever with nothing printed.
10
+ */
11
+ export async function request(url: string, options: RequestOptions = {}): Promise<Response> {
12
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, ...init } = options
13
+ try {
14
+ return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
15
+ } catch (error) {
16
+ throw new Error(`${url} ${describeFailure(error, timeoutMs)}`)
17
+ }
18
+ }
19
+
20
+ function describeFailure(error: unknown, timeoutMs: number) {
21
+ const name = (error as Error)?.name
22
+ if (name === 'TimeoutError' || name === 'AbortError') {
23
+ return `did not respond within ${Math.round(timeoutMs / 1000)}s`
24
+ }
25
+ // fetch reports every transport problem as "fetch failed"; the reason is on the cause.
26
+ const cause = (error as { cause?: unknown })?.cause
27
+ const detail =
28
+ cause instanceof AggregateError
29
+ ? cause.errors.map(codeOf).join(', ')
30
+ : codeOf(cause) ?? (error as Error)?.message ?? String(error)
31
+ return `failed: ${detail}`
32
+ }
33
+
34
+ function codeOf(error: unknown) {
35
+ if (error instanceof Error) return (error as NodeJS.ErrnoException).code ?? error.message
36
+ return String(error)
37
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://fzgame.dev/schema/fzgame.json",
4
+ "title": "FZGame pack manifest",
5
+ "type": "object",
6
+ "required": ["info", "formSchema", "resultSchema", "snapshotSchema", "gameDataSchema"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "info": {
11
+ "type": "object",
12
+ "description": "Catalog identity for the game pack",
13
+ "required": ["name", "slug"],
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "name": { "type": "string", "minLength": 2 },
17
+ "slug": {
18
+ "type": "string",
19
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
20
+ },
21
+ "version": { "type": "integer", "minimum": 1, "default": 1 },
22
+ "image": {
23
+ "type": "string",
24
+ "description": "Cover image URL or pack-relative path"
25
+ },
26
+ "description": { "type": "string" },
27
+ "author": { "type": "string" },
28
+ "git_url": { "type": "string", "format": "uri" }
29
+ }
30
+ },
31
+ "formSchema": {
32
+ "type": "object",
33
+ "description": "JSON Schema for the create/edit form (instance config). Optional JSON Forms uiSchema lives in formUiSchema."
34
+ },
35
+ "formUiSchema": {
36
+ "type": "object",
37
+ "description": "Optional JSON Forms / RJSF uiSchema for formSchema"
38
+ },
39
+ "resultSchema": {
40
+ "type": "object",
41
+ "description": "JSON Schema for the value the handler/finish event stores on the play session result"
42
+ },
43
+ "snapshotSchema": {
44
+ "type": "object",
45
+ "description": "JSON Schema for mid-play session snapshot (kit.setState / metadata.snapshot)"
46
+ },
47
+ "gameDataSchema": {
48
+ "type": "object",
49
+ "description": "JSON Schema for persistent game data on the instance (levels, content, save payload)"
50
+ },
51
+ "entry": {
52
+ "type": "object",
53
+ "additionalProperties": false,
54
+ "properties": {
55
+ "game": { "type": "string" },
56
+ "editor": { "type": "string" },
57
+ "handler": { "type": "string" }
58
+ }
59
+ },
60
+ "name": { "type": "string", "description": "Legacy alias for info.name" },
61
+ "slug": { "type": "string", "description": "Legacy alias for info.slug" },
62
+ "version": { "type": "integer", "description": "Legacy alias for info.version" },
63
+ "description": { "type": "string" },
64
+ "configSchema": {
65
+ "type": "object",
66
+ "description": "Legacy alias for formSchema"
67
+ }
68
+ }
69
+ }
package/src/init.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
2
+ import { dirname, join, relative, resolve, sep } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url))
6
+ const EXAMPLE_DIR = join(here, '..', 'example', 'click-counter')
7
+
8
+ export type InitOptions = { force?: boolean }
9
+
10
+ export async function initGame(targetDir: string, options: InitOptions = {}) {
11
+ const dest = resolve(targetDir)
12
+ const template = await stat(EXAMPLE_DIR).then(
13
+ (info) => info.isDirectory(),
14
+ () => false,
15
+ )
16
+ if (!template) {
17
+ throw new Error(
18
+ `Game template missing at ${EXAMPLE_DIR}. Reinstall @fzgame/cli, the "example" folder must ship with it.`,
19
+ )
20
+ }
21
+
22
+ await mkdir(dest, { recursive: true })
23
+ const existing = (await readdir(dest)).filter((name) => name !== '.DS_Store' && name !== '.git')
24
+ if (existing.length > 0 && !options.force) {
25
+ throw new Error(
26
+ `${dest} is not empty (${existing.slice(0, 5).join(', ')}${existing.length > 5 ? ', ...' : ''}). Pass --force to write into it.`,
27
+ )
28
+ }
29
+
30
+ const hasGitignore = await stat(join(dest, '.gitignore')).then(
31
+ () => true,
32
+ () => false,
33
+ )
34
+ await cp(EXAMPLE_DIR, dest, {
35
+ recursive: true,
36
+ filter: (source) => keep(source) && !(hasGitignore && source.endsWith(`${sep}.gitignore`)),
37
+ })
38
+ await ensureGitignore(dest)
39
+
40
+ const files: string[] = []
41
+ await walk(dest, (file) => {
42
+ files.push(relative(dest, file).split(sep).join('/'))
43
+ })
44
+ files.sort()
45
+ return { dir: dest, files }
46
+ }
47
+
48
+ /** Skip build output and installed dependencies so the scaffold stays a source tree. */
49
+ function keep(source: string) {
50
+ const name = source.split(sep).pop() ?? ''
51
+ return name !== 'dist' && name !== 'node_modules' && name !== '.DS_Store'
52
+ }
53
+
54
+ async function ensureGitignore(dest: string) {
55
+ const path = join(dest, '.gitignore')
56
+ let current = ''
57
+ try {
58
+ current = await readFile(path, 'utf8')
59
+ } catch {
60
+ /* no .gitignore yet */
61
+ }
62
+ if (/(^|\n)\s*dist\/?\s*(\n|$)/.test(current)) return
63
+ const next = current && !current.endsWith('\n') ? `${current}\n` : current
64
+ await writeFile(path, `${next}dist/\n`)
65
+ }
66
+
67
+ async function walk(dir: string, visit: (file: string) => void) {
68
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
69
+ if (entry.name === 'node_modules' || entry.name === '.git') continue
70
+ const abs = join(dir, entry.name)
71
+ if (entry.isDirectory()) await walk(abs, visit)
72
+ else visit(abs)
73
+ }
74
+ }
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ const require = createRequire(import.meta.url)
6
+
7
+ export const KIT_HINT = 'Run `pnpm --filter @fzgame/kit build` (monorepo) or `npm install` (installed CLI)'
8
+
9
+ type ExportTarget = string | { import?: ExportTarget; require?: ExportTarget; default?: ExportTarget }
10
+
11
+ type KitManifest = {
12
+ module?: string
13
+ main?: string
14
+ exports?: Record<string, ExportTarget>
15
+ }
16
+
17
+ /**
18
+ * The ESM file behind an export condition, which may be a bare path or a tree
19
+ * of conditions (`{ import: { types, default } }`).
20
+ */
21
+ function resolveTarget(target: ExportTarget | undefined): string | undefined {
22
+ if (typeof target === 'string') return target
23
+ if (!target) return undefined
24
+ return resolveTarget(target.import) ?? resolveTarget(target.default)
25
+ }
26
+
27
+ /**
28
+ * Absolute path of the kit's browser (ESM) bundle, the file the game loads as
29
+ * `/@fzgame/kit.js` in dev and as `game/vendor/kit.js` in a packed zip.
30
+ *
31
+ * The kit's own manifest decides the filename, so changing its build layout
32
+ * cannot silently ship a zip with no vendor script.
33
+ */
34
+ export function resolveKitFile(): string {
35
+ const manifestPath = require.resolve('@fzgame/kit/package.json')
36
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as KitManifest
37
+ const entry =
38
+ manifest.module ?? resolveTarget(manifest.exports?.['.']) ?? manifest.main ?? './dist/index.js'
39
+ return join(dirname(manifestPath), entry)
40
+ }