Files
MoonTVPlus/scripts/edgeone-build.mjs
T
mtvpls-turso 8e464c463b feat: add Turso (libSQL) storage support for EdgeOne deployment
- Add TursoAdapter implementing DatabaseAdapter interface using @libsql/client
- Add 'turso' storage type to db.ts storage selector (reuses D1Storage)
- Add init-turso.js script for database migration and admin initialization
- Add @libsql/client dependency to package.json
- Update next.config.js to exclude @libsql/client from client-side bundle
- Update edgeone-deploy.yml workflow with TURSO_URL and TURSO_TOKEN secrets
- Update edgeone-build.mjs runtime env keys to include Turso variables
- Update .env.edgeone.example with Turso deployment instructions
- Update README.md with Turso as recommended storage for EdgeOne

Turso provides free SQLite-compatible cloud database (libSQL) with:
- 500 databases, 9GB storage, 1B row reads/month on free tier
- HTTP API compatible with edge/serverless environments
- Full SQLite syntax compatibility, reuses existing migrations
2026-07-11 17:04:27 +00:00

483 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
function loadLocalEnvFile() {
const envPath = join(process.cwd(), '.env');
if (!existsSync(envPath)) {
return;
}
const content = readFileSync(envPath, 'utf8');
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match || process.env[match[1]] !== undefined) {
continue;
}
let value = match[2].trim();
const quote = value[0];
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
value = value.slice(1, -1);
}
if (quote === '"') {
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
process.env[match[1]] = value;
}
}
loadLocalEnvFile();
const runtimeEnvKeys = [
'USERNAME',
'PASSWORD',
'NEXT_PUBLIC_STORAGE_TYPE',
'UPSTASH_URL',
'UPSTASH_TOKEN',
'TURSO_URL',
'TURSO_TOKEN',
'TMDB_API_KEY',
'TMDB_IMAGE_DOMAIN',
'NEXT_PUBLIC_SITE_NAME',
'ANNOUNCEMENT',
'ENABLE_REGISTER',
'NEXT_PUBLIC_SEARCH_MAX_PAGE',
'NEXT_PUBLIC_DOUBAN_PROXY_TYPE',
'NEXT_PUBLIC_DOUBAN_PROXY',
'NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE',
'NEXT_PUBLIC_DOUBAN_IMAGE_PROXY',
'NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD',
'NEXT_PUBLIC_IMAGE_PROXY',
'NEXT_PUBLIC_IMAGE_PROXY_PREFIX',
'NEXT_PUBLIC_DISABLE_YELLOW_FILTER',
'DISABLE_USER_FOLDER',
'NEXT_PUBLIC_STORAGE_PREFIX',
'NEXT_PUBLIC_ENABLE_STREAM_PROXY',
'NEXT_PUBLIC_STREAM_PROXY_URL',
'NEXT_PUBLIC_ENABLE_TURNSTILE',
'TURNSTILE_SECRET_KEY',
'NEXT_PUBLIC_TURNSTILE_SITE_KEY',
'NEXT_PUBLIC_THEME',
'NEXT_PUBLIC_THEME_COLOR',
'NEXT_PUBLIC_APP_URL',
'ENABLE_TVBOX_SUBSCRIBE',
'TVBOX_SUBSCRIBE_PATH',
'TVBOX_SUBSCRIBE_TOKEN',
'WATCH_ROOM_ENABLED',
'WATCH_ROOM_WS_URL',
'WATCH_ROOM_SERVER_URL',
'WEBSOCKET_SECRET',
'WEB_PUSH_EMAIL',
'WEB_PUSH_PRIVATE_KEY',
'WEB_PUSH_PUBLIC_KEY',
'WEB_PUSH_BASEURL',
'NEXT_PUBLIC_BASE_PATH',
];
const edgeOneMiddlewareSkipPaths = [
'/login',
'/register',
'/oidc-register',
'/qr-login',
'/warning',
'/tv/login',
'/api/login',
'/api/register',
'/api/logout',
'/api/auth/oidc',
'/api/auth/qr',
'/api/auth/refresh',
'/api/server-config',
'/api/tvbox/subscribe',
];
function getRuntimeEnvLiteral() {
const env = {};
for (const key of runtimeEnvKeys) {
if (process.env[key] !== undefined) {
env[key] = process.env[key];
}
}
return JSON.stringify(env);
}
function replaceEnvLiterals(code, envLiteral) {
let output = '';
let cursor = 0;
let replaced = 0;
const needle = 'env: {';
while (true) {
const start = code.indexOf(needle, cursor);
if (start === -1) {
output += code.slice(cursor);
break;
}
let i = start + 'env: '.length;
let depth = 0;
let inString = false;
let escaped = false;
for (; i < code.length; i += 1) {
const char = code[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
i += 1;
break;
}
}
}
if (depth !== 0) {
console.warn('[edgeone-build] Unable to replace an env literal: malformed object');
output += code.slice(cursor);
break;
}
output += code.slice(cursor, start) + `env: ${envLiteral}`;
cursor = i;
replaced += 1;
}
if (replaced > 0) {
console.log(`[edgeone-build] Replaced ${replaced} generated env literal(s) with filtered runtime env`);
}
return output;
}
function patchEdgeFunctionEnvInjection() {
const edgeFunctionPath = join(process.cwd(), '.edgeone', 'edge-functions', 'index.js');
let code;
try {
code = readFileSync(edgeFunctionPath, 'utf8');
} catch {
return;
}
const marker = '/* edgeone-process-env-injected */';
const envLiteral = getRuntimeEnvLiteral();
code = replaceEnvLiterals(code, envLiteral);
const target = 'let request = context.request;';
if (!code.includes(marker) && !code.includes(target)) {
console.warn('[edgeone-build] Unable to patch edge function env injection: target not found');
} else if (!code.includes(marker)) {
code = code.replace(
target,
`${target}\n ${marker}\n if (typeof globalThis !== 'undefined' && globalThis.process?.env && context?.env) {\n Object.assign(globalThis.process.env, context.env);\n }`
);
}
const middlewareSignature = 'async function executeMiddleware({request}) {';
const middlewareMarker = '/* edgeone-middleware-env-injected */';
if (!code.includes(middlewareMarker) && !code.includes(middlewareSignature)) {
console.warn('[edgeone-build] Unable to patch middleware env injection: target not found');
} else if (!code.includes(middlewareMarker)) {
code = code.replace(
middlewareSignature,
`async function executeMiddleware({request, env}) {\n ${middlewareMarker}\n if (typeof globalThis !== 'undefined' && globalThis.process?.env && env) {\n Object.assign(globalThis.process.env, env);\n }`
);
}
const matcherFallbackMarker = '/* edgeone-middleware-matcher-fallback */';
const matcherFallbackTarget = ` if (!matchesPath(pathname, config.matcher)) {
return null;
}`;
if (!code.includes(matcherFallbackMarker) && !code.includes(matcherFallbackTarget)) {
console.warn('[edgeone-build] Unable to patch middleware matcher fallback: target not found');
} else if (!code.includes(matcherFallbackMarker)) {
code = code.replace(
matcherFallbackTarget,
`${matcherFallbackTarget}\n\n ${matcherFallbackMarker}\n const edgeOneMiddlewareSkipPaths = ${JSON.stringify(edgeOneMiddlewareSkipPaths)};\n if (edgeOneMiddlewareSkipPaths.some((path) => pathname.startsWith(path))) {\n return null;\n }`
);
}
writeFileSync(edgeFunctionPath, code);
console.log('[edgeone-build] Patched edge function process.env injection');
}
/**
* Fix /api returning HTTP 404 while body is correct and function logs show 200.
*
* EdgeOne route model:
* - rules before { handle: "filesystem" } = preprocess
* - filesystem tries static assets first (miss often becomes 404)
* - rules after filesystem = SSR / API back-to-origin
*
* If /api is not explicitly listed after filesystem, static miss 404 can leak to
* the client even when ssr-node successfully returns 200 + body.
*
* @see https://pages.edgeone.ai/document/building-output-configuration
*/
function isApiRouteRule(route) {
if (!route || typeof route.src !== 'string') {
return false;
}
return (
route.src === '^/api/(.*)$' ||
route.src === '^/api(?:/.*)?$' ||
route.src === '^/api$' ||
route.src === '/api/(.*)' ||
route.src === '/api/*'
);
}
function isCatchAllRoute(route) {
if (!route || typeof route.src !== 'string') {
return false;
}
return (
route.src === '/.*' ||
route.src === '^/.*$' ||
route.src === '^(.*)$' ||
route.src === '/(.*)'
);
}
function patchSsrNodeRoutes() {
const configPath = join(
process.cwd(),
'.edgeone',
'cloud-functions',
'ssr-node',
'config.json'
);
let raw;
try {
raw = readFileSync(configPath, 'utf8');
} catch {
console.warn('[edgeone-build] ssr-node config.json not found, skip API route patch');
return;
}
let config;
try {
config = JSON.parse(raw);
} catch (error) {
console.warn('[edgeone-build] Failed to parse ssr-node config.json:', error);
return;
}
const originalRoutes = Array.isArray(config.routes) ? config.routes : [];
// Drop previous patches / weak API rules; keep other generated rules.
const routes = originalRoutes.filter(
(route) => !isApiRouteRule(route) && !isCatchAllRoute(route)
);
let filesystemIdx = routes.findIndex((route) => route && route.handle === 'filesystem');
if (filesystemIdx === -1) {
routes.push({ handle: 'filesystem' });
filesystemIdx = routes.length - 1;
console.log('[edgeone-build] Inserted missing { handle: "filesystem" } into ssr-node routes');
}
const before = routes.slice(0, filesystemIdx + 1);
const after = routes.slice(filesystemIdx + 1).filter((route) => !isCatchAllRoute(route));
// Official full-stack pattern: API + catch-all AFTER filesystem → Node SSR handler.
// Do not write private fields into config.json — EdgeOne may reject unknown keys.
const apiRoute = {
src: '^/api(?:/.*)?$',
dest: '/api',
};
const apiRouteWithCapture = {
src: '^/api/(.*)$',
dest: '/api/$1',
};
const catchAll = {
src: '/.*',
};
// Short-circuit /api BEFORE filesystem so static layer never owns the request.
// Avoids "static miss 404 status + SSR body" composites on some EdgeOne builds.
const apiBeforeFilesystem = {
src: '^/api(?:/.*)?$',
dest: '/api',
};
const preFilesystem = before.slice(0, -1).filter((route) => !isApiRouteRule(route));
const filesystemRule = before[before.length - 1];
config.version = config.version || 3;
config.routes = [
...preFilesystem,
apiBeforeFilesystem,
filesystemRule,
apiRouteWithCapture,
apiRoute,
...after,
catchAll,
];
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
console.log(
'[edgeone-build] Patched ssr-node routes: /api forced before+after filesystem (fix API 404 status leak)'
);
}
/**
* Wrap ssr-node handler so Response.status is never dropped if the runtime
* re-constructs the response without status (defensive).
*/
function patchHandlerFile(handlerPath, code) {
const marker = '/* edgeone-api-status-guard */';
if (code.includes(marker)) {
console.log('[edgeone-build] ssr-node handler status guard already present');
return;
}
const guard = `
${marker}
function __edgeoneEnsureResponseStatus(response, fallbackStatus) {
if (!response) return response;
try {
const status = Number(response.status || fallbackStatus || 200);
if (status >= 100 && status <= 599 && response.status === status) {
return response;
}
if (typeof Response !== 'undefined' && (response instanceof Response || typeof response.headers?.get === 'function')) {
const headers = new Headers(response.headers || {});
return new Response(response.body, {
status: status >= 100 && status <= 599 ? status : 200,
statusText: response.statusText,
headers,
});
}
} catch (_) {
// ignore
}
return response;
}
function __edgeoneWrapHandler(fn) {
if (typeof fn !== 'function') return fn;
return async function __edgeonePatchedHandler(request, context) {
const response = await fn(request, context);
return __edgeoneEnsureResponseStatus(response, 200);
};
}
`;
let patched = code;
let applied = false;
if (/export\s+default\s+/.test(patched) && !applied) {
patched = `${guard}\n${patched.replace(
/export\s+default\s+/,
'const __edgeoneOriginalDefault = '
)}\nexport default __edgeoneWrapHandler(__edgeoneOriginalDefault);\n`;
applied = true;
}
if (!applied && /module\.exports\s*=/.test(patched)) {
patched = `${guard}\n${patched}\n;module.exports = __edgeoneWrapHandler(module.exports?.default || module.exports);\nif (module.exports && module.exports.default) { module.exports.default = __edgeoneWrapHandler(module.exports.default); }\n`;
applied = true;
}
if (!applied && /exports\.default\s*=/.test(patched)) {
patched = `${guard}\n${patched}\n;exports.default = __edgeoneWrapHandler(exports.default);\n`;
applied = true;
}
if (!applied) {
console.warn(
'[edgeone-build] Unable to wrap ssr-node handler export (unknown module shape); route patch still applied'
);
return;
}
writeFileSync(handlerPath, patched);
console.log(`[edgeone-build] Patched ssr-node handler status guard: ${handlerPath}`);
}
function patchSsrNodeHandlerStatus() {
const candidates = [
join(process.cwd(), '.edgeone', 'cloud-functions', 'ssr-node', 'handler.js'),
join(process.cwd(), '.edgeone', 'cloud-functions', 'ssr-node', 'index.js'),
join(process.cwd(), '.edgeone', 'cloud-functions', 'ssr-node', 'index.mjs'),
join(process.cwd(), '.edgeone', 'cloud-functions', 'ssr-node', 'handler.mjs'),
];
for (const handlerPath of candidates) {
try {
const code = readFileSync(handlerPath, 'utf8');
patchHandlerFile(handlerPath, code);
return;
} catch {
// try next
}
}
console.warn('[edgeone-build] ssr-node handler not found, skip status patch');
}
const isInsideEdgeOneBuilder = process.env.NEXT_PRIVATE_STANDALONE === 'true';
const command = isInsideEdgeOneBuilder
? 'BUILD_TARGET=edgeone EDGEONE_PAGES=1 pnpm build'
: 'BUILD_TARGET=edgeone EDGEONE_PAGES=1 edgeone makers build';
const child = spawn(command, {
stdio: 'inherit',
shell: true,
env: {
...process.env,
BUILD_TARGET: 'edgeone',
EDGEONE_PAGES: '1',
},
});
child.on('exit', (code, signal) => {
if (!signal && code === 0) {
for (const file of ['edgeone.json', 'package.json']) {
copyFileSync(join(process.cwd(), file), join(process.cwd(), '.edgeone', file));
}
patchEdgeFunctionEnvInjection();
patchSsrNodeRoutes();
patchSsrNodeHandlerStatus();
for (const envPath of [
join(process.cwd(), '.edgeone', '.env'),
join(process.cwd(), '.edgeone', 'cloud-functions', 'ssr-node', '.env'),
]) {
rmSync(envPath, { force: true });
}
}
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});