144 lines
6.9 KiB
JavaScript
Executable File
144 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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,
|
|
})
|
|
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 停止连接器')
|
|
})
|