#!/usr/bin/env node import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' import { findSpineExecutable, importSpineProject, normalizeSpineVersion } from './spine-cli.mjs' import { allowOrigin, originAllowed, validWebOrigin } from './connector-config.mjs' import { createConversionWorkspace, safeProjectName } from './spine-workspace.mjs' import { createZipArchive } from './zip-archive.mjs' const VERSION = '1.0.0' const HOST = process.env.SPINE_CONNECTOR_HOST || '127.0.0.1' const PORT = Math.max(1024, Math.min(65535, Number(process.env.SPINE_CONNECTOR_PORT) || 27843)) const MAX_BODY = 200 * 1024 * 1024 const pairing = new Map() function cors(response, origin) { if (origin) response.setHeader('Access-Control-Allow-Origin', origin) response.setHeader('Vary', 'Origin') response.setHeader('Access-Control-Allow-Private-Network', 'true') response.setHeader('Access-Control-Allow-Headers', 'Content-Type') response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') } function json(response, status, body, origin) { cors(response, origin) response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) response.end(JSON.stringify(body)) } function html(response, status, body) { response.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) response.end(`Spine粒子连接器
${body}
`) } async function readJson(request) { const chunks = [] let size = 0 for await (const chunk of request) { size += chunk.length if (size > MAX_BODY) throw new Error('请求数据不能超过 200MB') chunks.push(chunk) } return JSON.parse(Buffer.concat(chunks).toString('utf8')) } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]) } const server = createServer(async (request, response) => { const origin = request.headers.origin || '' const url = new URL(request.url || '/', `http://${HOST}:${PORT}`) if (request.method === 'OPTIONS') { cors(response, origin); response.writeHead(204); response.end(); return } try { if (request.method === 'GET' && url.pathname === '/v1/health') { const spinePath = await findSpineExecutable() const authorized = await originAllowed(origin) json(response, 200, { connectorVersion: VERSION, authorized, spineFound: !!spinePath, platform: process.platform, supportedVersions: ['3.8', '4.0', '4.1', '4.2', '4.3'], }, origin) return } if (request.method === 'GET' && url.pathname === '/pair') { const requestedOrigin = validWebOrigin(url.searchParams.get('origin')) if (!requestedOrigin) { html(response, 400, '

授权地址无效

'); return } const nonce = randomBytes(24).toString('hex') pairing.set(nonce, { origin: requestedOrigin, expires: Date.now() + 5 * 60 * 1000 }) html(response, 200, `

授权 Spine粒子编辑器

是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 .spine 文件?

${escapeHtml(requestedOrigin)}

`) return } if (request.method === 'POST' && url.pathname === '/pair') { const chunks = [] for await (const chunk of request) chunks.push(chunk) const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8')) const nonce = form.get('nonce') || '' const pending = pairing.get(nonce) pairing.delete(nonce) if (!pending || pending.expires < Date.now()) { html(response, 400, '

授权已过期,请返回编辑器重试

'); return } await allowOrigin(pending.origin) html(response, 200, `

授权成功

${escapeHtml(pending.origin)} 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。

`) return } if (request.method === 'POST' && url.pathname === '/v1/convert') { if (!(await originAllowed(origin))) { const pairUrl = `http://${HOST}:${PORT}/pair?origin=${encodeURIComponent(origin)}` json(response, 403, { code: 'ORIGIN_NOT_ALLOWED', message: '需要先授权当前网站使用本地 Spine 连接器', pairUrl }, origin) return } const spinePath = await findSpineExecutable() if (!spinePath) { json(response, 503, { code: 'SPINE_NOT_FOUND', message: '没有找到 Spine,请先安装 Spine 或设置 SPINE_EXECUTABLE' }, origin) return } const payload = await readJson(request) const workspace = await createConversionWorkspace(payload) try { const spineVersion = normalizeSpineVersion(workspace.spineVersion) const result = await importSpineProject({ executablePath: spinePath, inputPath: workspace.inputPath, outputPath: workspace.outputPath, projectName: workspace.projectName, spineVersion, workspaceRoot: workspace.root, }) const project = await workspace.readOutput() const projectFileName = `${safeProjectName(workspace.projectName)}.spine` const archive = createZipArchive([ { name: projectFileName, data: project }, ...workspace.archiveImages, ]) const fileName = `${safeProjectName(workspace.projectName)}.zip` const asciiFileName = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') cors(response, origin) response.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': archive.length, 'Content-Disposition': `attachment; filename="${asciiFileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`, 'X-Spine-File-Name': encodeURIComponent(fileName), 'X-Spine-Editor-Version': result.editorVersion, 'Access-Control-Expose-Headers': 'X-Spine-File-Name, X-Spine-Editor-Version', }) response.end(archive) } finally { await workspace.cleanup() } return } json(response, 404, { code: 'NOT_FOUND', message: '接口不存在' }, origin) } catch (error) { const message = error instanceof Error ? error.message : String(error) json(response, 500, { code: 'CONVERSION_FAILED', message }, origin) } }) server.listen(PORT, HOST, () => { console.log(`Spine粒子连接器 ${VERSION}`) console.log(`监听地址:http://${HOST}:${PORT}`) console.log('按 Ctrl+C 停止连接器') })