import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { Plugin } from 'vite';
|
|
import { Buffer } from 'node:buffer';
|
import { readFileSync } from 'node:fs';
|
import { isIP } from 'node:net';
|
import { relative, resolve } from 'node:path';
|
import { fileURLToPath } from 'node:url';
|
|
const PRODUCTION_VERSION = '0.1.0' as const;
|
const DEVELOPMENT_RELEASE = `${PRODUCTION_VERSION}-dev` as const;
|
const DEVELOPMENT_BASE_PATH = `/onlyoffice-plugins/${DEVELOPMENT_RELEASE}/eln-template-filler`;
|
const PLUGIN_GUID = 'asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565}';
|
const PRODUCTION_IFRAME_URL = `index.html?v=${PRODUCTION_VERSION}`;
|
const RELEASE_PATH_PREFIX = '/onlyoffice-plugins/';
|
const SDK_PATH_PREFIX = '/onlyoffice-sdk/';
|
const SDK_PROXY_ERROR = 'ONLYOFFICE SDK 代理失败\n';
|
const SDK_PROXY_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
const SDK_PROXY_TIMEOUT_MS = 5_000;
|
const EMPTY_TRANSLATIONS_MANIFEST = '[]\n';
|
const SDK_RESOURCES = new Map([
|
['/onlyoffice-sdk/v1/plugins.css', '/sdkjs-plugins/v1/plugins.css'],
|
['/onlyoffice-sdk/v1/plugins.js', '/sdkjs-plugins/v1/plugins.js'],
|
]);
|
const SDK_REQUEST_HEADERS = ['accept', 'if-none-match', 'if-modified-since'] as const;
|
const SDK_RESPONSE_HEADERS = ['content-type', 'etag', 'last-modified'] as const;
|
const NO_STORE_HEADERS = {
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
Expires: '0',
|
Pragma: 'no-cache',
|
} as const;
|
const pluginRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
const environmentNames = {
|
devHost: 'ONLYOFFICE_PLUGIN_DEV_HOST',
|
devPort: 'ONLYOFFICE_PLUGIN_DEV_PORT',
|
docsUrl: 'ONLYOFFICE_DOCS_URL',
|
hmrClientPort: 'ONLYOFFICE_PLUGIN_HMR_CLIENT_PORT',
|
hmrHost: 'ONLYOFFICE_PLUGIN_HMR_HOST',
|
hmrPath: 'ONLYOFFICE_PLUGIN_HMR_PATH',
|
hmrProtocol: 'ONLYOFFICE_PLUGIN_HMR_PROTOCOL',
|
publicHost: 'ONLYOFFICE_PLUGIN_PUBLIC_HOST',
|
} as const;
|
|
export interface OnlyOfficeDevOptions {
|
basePath: string;
|
docsUrl: string;
|
hmrClientPort: number;
|
hmrHost: string;
|
hmrPath?: string;
|
hmrProtocol: 'ws' | 'wss';
|
host: string;
|
manifestUrl: string;
|
port: number;
|
publicHost: string;
|
release: '0.1.0-dev';
|
startupId: string;
|
}
|
|
interface OnlyOfficeSdkProxyOptions {
|
docsUrl: string;
|
onError?: (category: 'network' | 'status' | 'timeout') => void;
|
timeoutMs?: number;
|
}
|
|
type OnlyOfficeSdkProxy = (request: IncomingMessage, response: ServerResponse, next: () => void) => Promise<void>;
|
|
function setNoStoreHeaders(response: ServerResponse): void {
|
for (const [name, value] of Object.entries(NO_STORE_HEADERS)) response.setHeader(name, value);
|
}
|
|
function endSdkProxyError(response: ServerResponse, method: string | undefined): void {
|
if (response.destroyed || response.writableEnded) return;
|
response.statusCode = 502;
|
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
response.end(method === 'HEAD' ? undefined : SDK_PROXY_ERROR);
|
}
|
|
async function cancelResponseBody(response: Response): Promise<void> {
|
try {
|
await response.body?.cancel();
|
} catch {
|
// The upstream connection may already be closed or aborted.
|
}
|
}
|
|
async function readBoundedResponseBody(response: Response): Promise<Buffer> {
|
const contentLengthHeader = response.headers.get('content-length');
|
const contentLength =
|
response.headers.has('content-encoding') || contentLengthHeader === null || !/^\d+$/u.test(contentLengthHeader) ? undefined : Number(contentLengthHeader);
|
if (contentLength !== undefined && (!Number.isSafeInteger(contentLength) || contentLength > SDK_PROXY_MAX_RESPONSE_BYTES)) {
|
await cancelResponseBody(response);
|
throw new Error('response-too-large');
|
}
|
|
if (!response.body) return Buffer.alloc(0);
|
|
const buffer = Buffer.allocUnsafe(contentLength ?? SDK_PROXY_MAX_RESPONSE_BYTES);
|
const reader = response.body.getReader();
|
let bytesRead = 0;
|
try {
|
while (true) {
|
const result = await reader.read();
|
if (result.done) break;
|
if (bytesRead + result.value.byteLength > SDK_PROXY_MAX_RESPONSE_BYTES) {
|
await reader.cancel();
|
throw new Error('response-too-large');
|
}
|
buffer.set(result.value, bytesRead);
|
bytesRead += result.value.byteLength;
|
}
|
} finally {
|
reader.releaseLock();
|
}
|
|
if (contentLength !== undefined && bytesRead !== contentLength) {
|
throw new Error('response-truncated');
|
}
|
return buffer.subarray(0, bytesRead);
|
}
|
|
export function createOnlyOfficeSdkProxy({
|
docsUrl,
|
onError = () => undefined,
|
timeoutMs = SDK_PROXY_TIMEOUT_MS,
|
}: OnlyOfficeSdkProxyOptions): OnlyOfficeSdkProxy {
|
return async (request, response, next) => {
|
const requestUrl = request.url ?? '/';
|
const queryIndex = requestUrl.indexOf('?');
|
const pathname = queryIndex === -1 ? requestUrl : requestUrl.slice(0, queryIndex);
|
const upstreamPath = SDK_RESOURCES.get(pathname);
|
if (!upstreamPath) {
|
next();
|
return;
|
}
|
|
setNoStoreHeaders(response);
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
response.statusCode = 405;
|
response.setHeader('Allow', 'GET, HEAD');
|
response.end('Method Not Allowed');
|
return;
|
}
|
|
const headers = new Headers();
|
for (const name of SDK_REQUEST_HEADERS) {
|
const value = request.headers[name];
|
if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value);
|
}
|
headers.set('accept-encoding', 'identity');
|
|
// Closing each upstream response lets fetch detect a declared-length truncation immediately.
|
headers.set('connection', 'close');
|
const abortController = new AbortController();
|
let clientDisconnected = false;
|
let timedOut = false;
|
const abortForClientDisconnect = (): void => {
|
clientDisconnected = true;
|
abortController.abort();
|
};
|
const abortForPrematureClose = (): void => {
|
if (!response.writableFinished) abortForClientDisconnect();
|
};
|
request.once('aborted', abortForClientDisconnect);
|
response.once('close', abortForPrematureClose);
|
const timeout = setTimeout(() => {
|
timedOut = true;
|
abortController.abort();
|
}, timeoutMs);
|
timeout.unref();
|
|
try {
|
const upstreamResponse = await fetch(`${docsUrl}${upstreamPath}`, {
|
headers,
|
method: request.method,
|
redirect: 'error',
|
signal: abortController.signal,
|
});
|
if (upstreamResponse.status === 304) {
|
response.statusCode = 304;
|
for (const name of SDK_RESPONSE_HEADERS) {
|
const value = upstreamResponse.headers.get(name);
|
if (value !== null) response.setHeader(name, value);
|
}
|
response.end();
|
return;
|
}
|
if (!upstreamResponse.ok) {
|
await cancelResponseBody(upstreamResponse);
|
onError('status');
|
endSdkProxyError(response, request.method);
|
return;
|
}
|
|
const body = request.method === 'HEAD' ? undefined : await readBoundedResponseBody(upstreamResponse);
|
if (clientDisconnected) return;
|
|
response.statusCode = upstreamResponse.status;
|
for (const name of SDK_RESPONSE_HEADERS) {
|
const value = upstreamResponse.headers.get(name);
|
if (value !== null) response.setHeader(name, value);
|
}
|
if (request.method === 'HEAD') response.end();
|
else response.end(body);
|
} catch {
|
if (clientDisconnected) return;
|
onError(timedOut ? 'timeout' : 'network');
|
endSdkProxyError(response, request.method);
|
} finally {
|
clearTimeout(timeout);
|
request.off('aborted', abortForClientDisconnect);
|
response.off('close', abortForPrematureClose);
|
}
|
};
|
}
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
}
|
|
function containsWhitespaceOrControl(value: string): boolean {
|
for (const character of value) {
|
const codePoint = character.codePointAt(0);
|
if (character.trim() === '' || (codePoint !== undefined && (codePoint < 32 || codePoint === 127))) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
function parseUrlHost(value: string, environmentName: string): string {
|
if (!value || containsWhitespaceOrControl(value) || /[\\/?#@]/u.test(value)) {
|
throw new Error(`${environmentName} 必须是不含协议、路径、空白或控制字符的 host`);
|
}
|
|
const isBracketed = value.startsWith('[') && value.endsWith(']');
|
if ((!isBracketed && value.includes(':')) || value.startsWith('[') !== value.endsWith(']')) {
|
throw new Error(`${environmentName} 的 IPv6 host 必须使用方括号且不能包含端口`);
|
}
|
|
try {
|
const parsed = new URL(`http://${value}`);
|
if (!parsed.hostname || parsed.port || parsed.pathname !== '/') {
|
throw new Error('invalid host');
|
}
|
} catch {
|
throw new Error(`${environmentName} 不是有效的 host`);
|
}
|
|
return value;
|
}
|
|
function parseListenHost(value: string, environmentName: string): string {
|
if (value.startsWith('[') || value.endsWith(']')) {
|
throw new Error(`${environmentName} 的监听 IPv6 host 必须使用裸地址`);
|
}
|
if (value.includes(':')) {
|
if (isIP(value) !== 6) {
|
throw new Error(`${environmentName} 不是有效的监听 host`);
|
}
|
return value;
|
}
|
|
return parseUrlHost(value, environmentName);
|
}
|
|
function parsePort(value: string | undefined, fallback: number, environmentName: string): number {
|
if (value === undefined) {
|
return fallback;
|
}
|
if (!/^[1-9]\d*$/u.test(value)) {
|
throw new Error(`${environmentName} 必须是 1..65535 的十进制整数`);
|
}
|
|
const port = Number(value);
|
if (port > 65_535) {
|
throw new Error(`${environmentName} 必须是 1..65535 的十进制整数`);
|
}
|
return port;
|
}
|
|
function parseHmrPath(value: string | undefined): string | undefined {
|
if (value === undefined) return undefined;
|
if (!/^\/[A-Za-z0-9/_-]+$/u.test(value) || value === '/') {
|
throw new Error(`${environmentNames.hmrPath} 必须是以 / 开头且不含空白、query 或 fragment 的路径`);
|
}
|
return value;
|
}
|
|
function parseDocsUrl(value: string): string {
|
let parsed: URL;
|
try {
|
if (containsWhitespaceOrControl(value)) {
|
throw new Error('invalid URL');
|
}
|
parsed = new URL(value);
|
} catch {
|
throw new Error(`${environmentNames.docsUrl} 必须是有效的 http(s) URL`);
|
}
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
throw new Error(`${environmentNames.docsUrl} 必须是无 username/password、query 或 hash 的 http(s) URL`);
|
}
|
|
const pathname = parsed.pathname.replace(/\/+$/u, '');
|
return `${parsed.origin}${pathname}`;
|
}
|
|
function createStartupId(now: Date): string {
|
if (!Number.isFinite(now.getTime()) || now.getUTCFullYear() < 0 || now.getUTCFullYear() > 9999) {
|
throw new Error('now 必须是可表示为四位 UTC 年份的有效日期');
|
}
|
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
return `${String(now.getUTCFullYear()).padStart(4, '0')}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`;
|
}
|
|
export function resolveOnlyOfficeDevOptions(environment: NodeJS.ProcessEnv, now: Date = new Date()): OnlyOfficeDevOptions {
|
const host = parseListenHost(environment[environmentNames.devHost] ?? '0.0.0.0', environmentNames.devHost);
|
const port = parsePort(environment[environmentNames.devPort], 4173, environmentNames.devPort);
|
const publicHost = parseUrlHost(environment[environmentNames.publicHost] ?? 'localhost', environmentNames.publicHost);
|
const hmrHost = parseUrlHost(environment[environmentNames.hmrHost] ?? publicHost, environmentNames.hmrHost);
|
const hmrPath = parseHmrPath(environment[environmentNames.hmrPath]);
|
const hmrProtocol = environment[environmentNames.hmrProtocol] ?? 'ws';
|
if (hmrProtocol !== 'ws' && hmrProtocol !== 'wss') {
|
throw new Error(`${environmentNames.hmrProtocol} 只接受 ws 或 wss`);
|
}
|
const hmrClientPort = parsePort(environment[environmentNames.hmrClientPort], port, environmentNames.hmrClientPort);
|
const docsUrl = parseDocsUrl(environment[environmentNames.docsUrl] ?? 'http://localhost:20000');
|
const startupId = createStartupId(now);
|
const publicProtocol = hmrProtocol === 'wss' ? 'https' : 'http';
|
|
return {
|
basePath: DEVELOPMENT_BASE_PATH,
|
docsUrl,
|
hmrClientPort,
|
hmrHost,
|
...(hmrPath ? { hmrPath } : {}),
|
hmrProtocol,
|
host,
|
manifestUrl: `${publicProtocol}://${publicHost}:${port}${DEVELOPMENT_BASE_PATH}/config.json?v=${startupId}`,
|
port,
|
publicHost,
|
release: DEVELOPMENT_RELEASE,
|
startupId,
|
};
|
}
|
|
export function createDevelopmentManifest(source: unknown, options: OnlyOfficeDevOptions): Record<string, unknown> {
|
if (!isRecord(source) || source.guid !== PLUGIN_GUID) {
|
throw new Error('ONLYOFFICE plugin manifest GUID 必须保持正式身份');
|
}
|
if (source.version !== PRODUCTION_VERSION) {
|
throw new Error(`ONLYOFFICE plugin manifest version 必须为 ${PRODUCTION_VERSION}`);
|
}
|
|
const variations = source.variations;
|
const firstVariation = Array.isArray(variations) ? variations[0] : undefined;
|
if (!isRecord(firstVariation) || firstVariation.url !== PRODUCTION_IFRAME_URL) {
|
throw new Error(`ONLYOFFICE plugin manifest 的首个 variation URL 必须为 ${PRODUCTION_IFRAME_URL}`);
|
}
|
|
const manifest = structuredClone(source);
|
const manifestVariations = manifest.variations as Record<string, unknown>[];
|
manifestVariations[0].url = `index.html?v=${options.startupId}`;
|
return manifest;
|
}
|
|
export function onlyOfficeDevelopmentPlugin(options: OnlyOfficeDevOptions): Plugin {
|
const source = JSON.parse(readFileSync(resolve(pluginRoot, 'config.json'), 'utf8')) as unknown;
|
const manifest = `${JSON.stringify(createDevelopmentManifest(source, options), null, 2)}\n`;
|
const manifestPath = `${options.basePath}/config.json`;
|
const indexPath = `${options.basePath}/index.html`;
|
const translationsPath = `${options.basePath}/translations/langs.json`;
|
const developmentResourcePath = `${options.basePath}/`;
|
const developmentResourcePrefixes = ['@fs/', '@id/', '@vite/', 'node_modules/', 'src/'];
|
const fullReloadFiles = new Set(['src/composables/useTemplateAnnotator.ts', 'src/main.ts']);
|
const iconPaths = new Map([
|
[`${options.basePath}/resources/icon.png`, 'resources/icon.png'],
|
[`${options.basePath}/resources/icon@2x.png`, 'resources/icon@2x.png'],
|
]);
|
const sdkProxy = createOnlyOfficeSdkProxy({
|
docsUrl: options.docsUrl,
|
onError: (category) => console.error(`[onlyoffice-sdk-proxy] ${category}`),
|
});
|
let viteBase = '/';
|
|
return {
|
apply: 'serve',
|
name: 'onlyoffice-development',
|
config() {
|
return {
|
server: {
|
headers: NO_STORE_HEADERS,
|
},
|
};
|
},
|
configResolved(config) {
|
viteBase = config.base;
|
},
|
configureServer(server) {
|
let manifestUrlPrinted = false;
|
const printManifestUrl = (): void => {
|
if (manifestUrlPrinted) return;
|
manifestUrlPrinted = true;
|
server.config.logger.info(options.manifestUrl);
|
};
|
if (server.httpServer?.listening) printManifestUrl();
|
else server.httpServer?.once('listening', printManifestUrl);
|
|
server.middlewares.use((request, response, next) => {
|
setNoStoreHeaders(response);
|
|
const requestUrl = request.url ?? '/';
|
const queryIndex = requestUrl.indexOf('?');
|
const pathname = queryIndex === -1 ? requestUrl : requestUrl.slice(0, queryIndex);
|
const query = queryIndex === -1 ? '' : requestUrl.slice(queryIndex);
|
|
if (pathname.startsWith(SDK_PATH_PREFIX)) {
|
void sdkProxy(request, response, () => {
|
response.statusCode = 404;
|
response.end('Not Found');
|
});
|
return;
|
}
|
|
if (pathname === manifestPath) {
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
response.statusCode = 405;
|
response.setHeader('Allow', 'GET, HEAD');
|
response.end('Method Not Allowed');
|
return;
|
}
|
|
response.statusCode = 200;
|
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
if (request.method === 'HEAD') response.end();
|
else response.end(manifest);
|
return;
|
}
|
|
if (pathname === translationsPath) {
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
response.statusCode = 405;
|
response.setHeader('Allow', 'GET, HEAD');
|
response.end('Method Not Allowed');
|
return;
|
}
|
response.statusCode = 200;
|
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
if (request.method === 'HEAD') response.end();
|
else response.end(EMPTY_TRANSLATIONS_MANIFEST);
|
return;
|
}
|
|
if (pathname === indexPath) {
|
request.url = `${server.config.base}index.html${query}`;
|
next();
|
return;
|
}
|
|
const iconPath = iconPaths.get(pathname);
|
if (iconPath && (request.method === 'GET' || request.method === 'HEAD')) {
|
request.url = `${server.config.base}${iconPath}${query}`;
|
next();
|
return;
|
}
|
|
if (pathname.startsWith(developmentResourcePath)) {
|
const resourcePath = pathname.slice(developmentResourcePath.length);
|
if (developmentResourcePrefixes.some((prefix) => resourcePath.startsWith(prefix))) {
|
next();
|
return;
|
}
|
}
|
|
if (pathname.startsWith(RELEASE_PATH_PREFIX)) {
|
response.statusCode = 404;
|
response.end('Not Found');
|
return;
|
}
|
|
next();
|
});
|
},
|
handleHotUpdate(context) {
|
const file = relative(pluginRoot, resolve(context.file)).replaceAll('\\', '/');
|
if (file === 'config.json') {
|
context.server.config.logger.warn('config.json 已变化,请重启 Dev Server 后重新打开编辑器会话。');
|
context.server.ws.send({ path: '*', type: 'full-reload' });
|
return [];
|
}
|
if (fullReloadFiles.has(file) || file.startsWith('src/onlyoffice/')) {
|
context.server.ws.send({ path: '*', type: 'full-reload' });
|
return [];
|
}
|
},
|
transformIndexHtml: {
|
order: 'post',
|
handler(html) {
|
const sdkScriptPath = new URL('../v1/plugins.js', `http://vite.invalid${viteBase}index.html`).pathname;
|
const sdkStylePath = new URL('../v1/plugins.css', `http://vite.invalid${viteBase}index.html`).pathname;
|
return html.replaceAll(sdkStylePath, '/onlyoffice-sdk/v1/plugins.css').replaceAll(sdkScriptPath, '/onlyoffice-sdk/v1/plugins.js');
|
},
|
},
|
};
|
}
|