Files
2026-09-05 18:45:43 +08:00

145 lines
6.9 KiB
JavaScript
Executable File

#!/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(`<!doctype html><meta charset="utf-8"><title>Spine粒子连接器</title><style>body{font:16px system-ui;max-width:620px;margin:60px auto;padding:24px;color:#e9ebff;background:#10131e}main{padding:28px;border:1px solid #38415d;border-radius:12px;background:#181e2c}button{padding:10px 18px;border:0;border-radius:7px;color:white;background:#5b5ce2;cursor:pointer}code{word-break:break-all;color:#aeb8e8}</style><main>${body}</main>`)
}
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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, '<h2>授权地址无效</h2>'); return }
const nonce = randomBytes(24).toString('hex')
pairing.set(nonce, { origin: requestedOrigin, expires: Date.now() + 5 * 60 * 1000 })
html(response, 200, `<h2>授权 Spine粒子编辑器</h2><p>是否允许下面的网站调用本机 Spine,将它生成的 JSON 转换为 <code>.spine</code> 文件?</p><p><code>${escapeHtml(requestedOrigin)}</code></p><form method="post" action="/pair"><input type="hidden" name="nonce" value="${nonce}"><button type="submit">允许此网站</button></form>`)
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, '<h2>授权已过期,请返回编辑器重试</h2>'); return }
await allowOrigin(pending.origin)
html(response, 200, `<h2>授权成功</h2><p><code>${escapeHtml(pending.origin)}</code> 现在可以生成 Spine 工程。请关闭此页面并回到编辑器重试。</p>`)
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 停止连接器')
})