@kne/fastify-app-manager 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ const SECRET_MASK = '********';
2
+
3
+ const pickHostEnv = (passthroughEnvKeys = []) => {
4
+ const result = {};
5
+ for (const key of passthroughEnvKeys) {
6
+ if (Object.prototype.hasOwnProperty.call(process.env, key) && process.env[key] != null) {
7
+ result[key] = process.env[key];
8
+ }
9
+ }
10
+ return result;
11
+ };
12
+
13
+ const mergeEnv = ({ passthroughEnvKeys = [], appEnv = {}, port }) => {
14
+ const merged = Object.assign({}, pickHostEnv(passthroughEnvKeys), appEnv || {}, {
15
+ PORT: String(port)
16
+ });
17
+ return merged;
18
+ };
19
+
20
+ const isSecretKey = (key, pattern) => {
21
+ const re = pattern instanceof RegExp ? pattern : new RegExp(pattern || '(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)', 'i');
22
+ return re.test(key);
23
+ };
24
+
25
+ const maskEnvForResponse = (appEnv = {}, { secretEnvKeyPattern } = {}) => {
26
+ const masked = {};
27
+ for (const [key, value] of Object.entries(appEnv || {})) {
28
+ if (isSecretKey(key, secretEnvKeyPattern)) {
29
+ masked[key] = value == null || value === '' ? null : SECRET_MASK;
30
+ } else {
31
+ masked[key] = value;
32
+ }
33
+ }
34
+ return masked;
35
+ };
36
+
37
+ /**
38
+ * merge patch into current env.
39
+ * null => delete key
40
+ * SECRET_MASK => keep existing
41
+ * other => set
42
+ */
43
+ const applyEnvPatch = (current = {}, patch = {}, { secretEnvKeyPattern } = {}) => {
44
+ const next = Object.assign({}, current);
45
+ for (const [key, value] of Object.entries(patch || {})) {
46
+ if (value === null) {
47
+ delete next[key];
48
+ continue;
49
+ }
50
+ if (value === SECRET_MASK && isSecretKey(key, secretEnvKeyPattern)) {
51
+ continue;
52
+ }
53
+ next[key] = value;
54
+ }
55
+ return next;
56
+ };
57
+
58
+ const PM2_CONFIG_KEYS = ['exec_mode', 'instances', 'autorestart', 'max_memory_restart', 'max_restarts', 'min_uptime', 'kill_timeout', 'merge_logs'];
59
+
60
+ const mergePm2Config = (defaults = {}, appConfig = {}) => {
61
+ const merged = Object.assign({}, defaults);
62
+ for (const key of PM2_CONFIG_KEYS) {
63
+ if (appConfig && Object.prototype.hasOwnProperty.call(appConfig, key) && appConfig[key] != null) {
64
+ merged[key] = appConfig[key];
65
+ }
66
+ }
67
+ return merged;
68
+ };
69
+
70
+ module.exports = {
71
+ SECRET_MASK,
72
+ pickHostEnv,
73
+ mergeEnv,
74
+ isSecretKey,
75
+ maskEnvForResponse,
76
+ applyEnvPatch,
77
+ PM2_CONFIG_KEYS,
78
+ mergePm2Config
79
+ };
@@ -0,0 +1,52 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const { EventEmitter } = require('node:events');
4
+
5
+ const createLogHub = () => {
6
+ const emitter = new EventEmitter();
7
+ emitter.setMaxListeners(0);
8
+ return emitter;
9
+ };
10
+
11
+ const ensureLogFiles = async appRoot => {
12
+ const logsDir = path.join(appRoot, 'logs');
13
+ await fs.ensureDir(logsDir);
14
+ const outFile = path.join(logsDir, 'out.log');
15
+ const errFile = path.join(logsDir, 'err.log');
16
+ await fs.ensureFile(outFile);
17
+ await fs.ensureFile(errFile);
18
+ return { logsDir, outFile, errFile };
19
+ };
20
+
21
+ const appendLog = async (filePath, content) => {
22
+ await fs.appendFile(filePath, content.endsWith('\n') ? content : `${content}\n`);
23
+ };
24
+
25
+ const readLogTail = async (filePath, { perPage = 100, currentPage = 1 } = {}) => {
26
+ if (!(await fs.pathExists(filePath))) {
27
+ return { pageData: [], totalCount: 0 };
28
+ }
29
+ const text = await fs.readFile(filePath, 'utf8');
30
+ const lines = text.split(/\r?\n/).filter((line, idx, arr) => !(idx === arr.length - 1 && line === ''));
31
+ const totalCount = lines.length;
32
+ const start = Math.max(0, totalCount - perPage * currentPage);
33
+ const end = Math.max(0, totalCount - perPage * (currentPage - 1));
34
+ const pageData = lines.slice(start, end).map((content, i) => ({
35
+ line: start + i + 1,
36
+ content
37
+ }));
38
+ return { pageData: pageData.reverse(), totalCount };
39
+ };
40
+
41
+ const readLastLines = async (filePath, n = 100) => {
42
+ const { pageData } = await readLogTail(filePath, { perPage: n, currentPage: 1 });
43
+ return pageData.reverse();
44
+ };
45
+
46
+ module.exports = {
47
+ createLogHub,
48
+ ensureLogFiles,
49
+ appendLog,
50
+ readLogTail,
51
+ readLastLines
52
+ };
@@ -0,0 +1,39 @@
1
+ const { Transform } = require('node:stream');
2
+
3
+ const createSseReply = (reply, { heartbeatMs = 15000 } = {}) => {
4
+ reply.hijack();
5
+ reply.raw.writeHead(200, {
6
+ 'Content-Type': 'text/event-stream',
7
+ 'Cache-Control': 'no-cache',
8
+ Connection: 'keep-alive',
9
+ 'X-Accel-Buffering': 'no'
10
+ });
11
+
12
+ const writeEvent = (event, data) => {
13
+ if (event) {
14
+ reply.raw.write(`event: ${event}\n`);
15
+ }
16
+ reply.raw.write(`data: ${typeof data === 'string' ? data : JSON.stringify(data)}\n\n`);
17
+ };
18
+
19
+ const heartbeat = setInterval(() => {
20
+ reply.raw.write(`: ping\n\n`);
21
+ }, heartbeatMs);
22
+
23
+ const close = () => {
24
+ clearInterval(heartbeat);
25
+ try {
26
+ reply.raw.end();
27
+ } catch (e) {
28
+ // ignore
29
+ }
30
+ };
31
+
32
+ reply.raw.on('close', close);
33
+
34
+ return { writeEvent, close, raw: reply.raw };
35
+ };
36
+
37
+ module.exports = {
38
+ createSseReply
39
+ };
@@ -0,0 +1,70 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const { Sequelize } = require('sequelize');
4
+ const { listSqlFiles } = require('./validatePackage');
5
+
6
+ const SQL_MIGRATIONS_TABLE = '_fs_sql_migrations';
7
+
8
+ /**
9
+ * Run .sql files under server/{sqlPath} with the same tracking semantics as @kne/fastify-sequelize.
10
+ */
11
+ const runSqlMigrations = async ({ serverDir, sqlPath = 'sql', env = {} }) => {
12
+ const sqlDir = path.join(serverDir, sqlPath);
13
+ const files = await listSqlFiles(sqlDir);
14
+ if (!files.length) {
15
+ return { executed: [], skipped: true };
16
+ }
17
+
18
+ const sequelize = createSequelizeFromEnv(env);
19
+ try {
20
+ await sequelize.authenticate();
21
+ await sequelize.query(
22
+ `CREATE TABLE IF NOT EXISTS ${SQL_MIGRATIONS_TABLE} (
23
+ name VARCHAR(255) PRIMARY KEY,
24
+ executed_at DATETIME
25
+ )`
26
+ );
27
+
28
+ const [rows] = await sequelize.query(`SELECT name FROM ${SQL_MIGRATIONS_TABLE}`);
29
+ const done = new Set((rows || []).map(r => r.name));
30
+ const executed = [];
31
+
32
+ for (const file of files) {
33
+ if (done.has(file)) {
34
+ continue;
35
+ }
36
+ const sql = await fs.readFile(path.join(sqlDir, file), 'utf8');
37
+ await sequelize.query(sql);
38
+ await sequelize.query(`INSERT INTO ${SQL_MIGRATIONS_TABLE} (name, executed_at) VALUES (?, ?)`, {
39
+ replacements: [file, new Date().toISOString()]
40
+ });
41
+ executed.push(file);
42
+ }
43
+ return { executed, skipped: false };
44
+ } finally {
45
+ await sequelize.close();
46
+ }
47
+ };
48
+
49
+ const createSequelizeFromEnv = env => {
50
+ const dialect = env.DB_DIALECT || 'sqlite';
51
+ if (dialect === 'sqlite') {
52
+ return new Sequelize({
53
+ dialect: 'sqlite',
54
+ storage: env.DB_HOST || env.DB_STORAGE || path.join(process.cwd(), 'data.db'),
55
+ logging: false
56
+ });
57
+ }
58
+ return new Sequelize(env.DB_DATABASE, env.DB_USERNAME, env.DB_PASSWORD, {
59
+ host: env.DB_HOST || '127.0.0.1',
60
+ port: env.DB_PORT ? Number(env.DB_PORT) : undefined,
61
+ dialect,
62
+ logging: false
63
+ });
64
+ };
65
+
66
+ module.exports = {
67
+ SQL_MIGRATIONS_TABLE,
68
+ runSqlMigrations,
69
+ createSequelizeFromEnv
70
+ };
@@ -0,0 +1,55 @@
1
+ const rewriteLocation = (location, mountPrefix) => {
2
+ if (!location || !mountPrefix) {
3
+ return location;
4
+ }
5
+ if (/^https?:\/\//i.test(location) || location.startsWith('//')) {
6
+ return location;
7
+ }
8
+ if (location.startsWith(mountPrefix)) {
9
+ return location;
10
+ }
11
+ if (location.startsWith('/')) {
12
+ return `${mountPrefix}${location}`.replace(/\/{2,}/g, '/').replace(':/', '://');
13
+ }
14
+ return location;
15
+ };
16
+
17
+ const rewriteSetCookiePath = (setCookie, mountPrefix) => {
18
+ if (!setCookie || !mountPrefix) {
19
+ return setCookie;
20
+ }
21
+ const cookies = Array.isArray(setCookie) ? setCookie : [setCookie];
22
+ return cookies.map(cookie => {
23
+ if (/;\s*Path=/i.test(cookie)) {
24
+ return cookie.replace(/;\s*Path=\/?(?=[;]|$)/i, `; Path=${mountPrefix}`);
25
+ }
26
+ return `${cookie}; Path=${mountPrefix}`;
27
+ });
28
+ };
29
+
30
+ const TEXTUAL_TYPES = /text\/|javascript|json|xml|svg/i;
31
+
32
+ const shouldRewriteBody = contentType => TEXTUAL_TYPES.test(contentType || '');
33
+
34
+ const rewriteBody = (body, mountPrefix, prefixes = ['/static', '/api', '/account']) => {
35
+ if (!body || !mountPrefix) {
36
+ return body;
37
+ }
38
+ let text = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
39
+ for (const p of prefixes) {
40
+ const from = `"${p}/`;
41
+ const to = `"${mountPrefix}${p}/`;
42
+ text = text.split(from).join(to);
43
+ const from2 = `'${p}/`;
44
+ const to2 = `'${mountPrefix}${p}/`;
45
+ text = text.split(from2).join(to2);
46
+ }
47
+ return text;
48
+ };
49
+
50
+ module.exports = {
51
+ rewriteLocation,
52
+ rewriteSetCookiePath,
53
+ shouldRewriteBody,
54
+ rewriteBody
55
+ };
@@ -0,0 +1,96 @@
1
+ const path = require('node:path');
2
+ const pm2 = require('pm2');
3
+ const { mergePm2Config } = require('./env');
4
+
5
+ const connect = () =>
6
+ new Promise((resolve, reject) => {
7
+ pm2.connect(err => (err ? reject(err) : resolve()));
8
+ });
9
+
10
+ const disconnect = () => {
11
+ try {
12
+ pm2.disconnect();
13
+ } catch (e) {
14
+ // ignore
15
+ }
16
+ };
17
+
18
+ const describe = name =>
19
+ new Promise((resolve, reject) => {
20
+ pm2.describe(name, (err, list) => {
21
+ if (err) {
22
+ return reject(err);
23
+ }
24
+ resolve(list || []);
25
+ });
26
+ });
27
+
28
+ const deleteProcess = name =>
29
+ new Promise(resolve => {
30
+ pm2.delete(name, () => resolve());
31
+ });
32
+
33
+ const startApp = async ({ pm2Name, cwd, env, pm2Defaults, pm2Config, outFile, errorFile }) => {
34
+ const cfg = mergePm2Config(pm2Defaults, pm2Config);
35
+ const existing = await describe(pm2Name);
36
+ if (existing.length) {
37
+ await deleteProcess(pm2Name);
38
+ }
39
+
40
+ const script = path.join(cwd, 'index.js');
41
+ return new Promise((resolve, reject) => {
42
+ pm2.start(
43
+ {
44
+ name: pm2Name,
45
+ script,
46
+ cwd,
47
+ env,
48
+ exec_mode: cfg.exec_mode,
49
+ instances: cfg.instances,
50
+ autorestart: cfg.autorestart,
51
+ max_memory_restart: cfg.max_memory_restart,
52
+ max_restarts: cfg.max_restarts,
53
+ min_uptime: cfg.min_uptime,
54
+ kill_timeout: cfg.kill_timeout,
55
+ merge_logs: cfg.merge_logs,
56
+ out_file: outFile,
57
+ error_file: errorFile,
58
+ time: true
59
+ },
60
+ (err, apps) => (err ? reject(err) : resolve(apps))
61
+ );
62
+ });
63
+ };
64
+
65
+ const stopApp = name =>
66
+ new Promise((resolve, reject) => {
67
+ pm2.stop(name, err => (err ? reject(err) : resolve()));
68
+ });
69
+
70
+ const restartApp = name =>
71
+ new Promise((resolve, reject) => {
72
+ pm2.restart(name, err => (err ? reject(err) : resolve()));
73
+ });
74
+
75
+ const list = () =>
76
+ new Promise((resolve, reject) => {
77
+ pm2.list((err, list) => (err ? reject(err) : resolve(list || [])));
78
+ });
79
+
80
+ const launchBus = () =>
81
+ new Promise((resolve, reject) => {
82
+ pm2.launchBus((err, bus) => (err ? reject(err) : resolve(bus)));
83
+ });
84
+
85
+ module.exports = {
86
+ connect,
87
+ disconnect,
88
+ describe,
89
+ deleteProcess,
90
+ startApp,
91
+ stopApp,
92
+ restartApp,
93
+ list,
94
+ launchBus,
95
+ raw: pm2
96
+ };
@@ -0,0 +1,29 @@
1
+ const net = require('node:net');
2
+
3
+ const isPortFree = port =>
4
+ new Promise(resolve => {
5
+ const server = net.createServer();
6
+ server.unref();
7
+ server.once('error', () => resolve(false));
8
+ server.once('listening', () => {
9
+ server.close(() => resolve(true));
10
+ });
11
+ server.listen(port, '127.0.0.1');
12
+ });
13
+
14
+ module.exports = {
15
+ isPortFree,
16
+ async allocate({ models, portMin, portMax, excludePorts = [] }) {
17
+ const rows = await models.app.findAll({ attributes: ['port'] });
18
+ const used = new Set([...rows.map(r => r.port), ...excludePorts]);
19
+ for (let port = portMin; port <= portMax; port++) {
20
+ if (used.has(port)) {
21
+ continue;
22
+ }
23
+ if (await isPortFree(port)) {
24
+ return port;
25
+ }
26
+ }
27
+ throw new Error(`No free port in range ${portMin}-${portMax}`);
28
+ }
29
+ };
@@ -0,0 +1,59 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+
4
+ const ENTRY_CANDIDATES = ['index.html', 'entry.html', 'entry-prod.html'];
5
+
6
+ const validatePackageRoot = async rootDir => {
7
+ const errors = [];
8
+ const rootPkg = path.join(rootDir, 'package.json');
9
+ const serverPkg = path.join(rootDir, 'server', 'package.json');
10
+ const serverIndex = path.join(rootDir, 'server', 'index.js');
11
+ const buildDir = path.join(rootDir, 'build');
12
+
13
+ if (!(await fs.pathExists(rootPkg))) {
14
+ errors.push('missing root package.json');
15
+ }
16
+ if (!(await fs.pathExists(serverPkg))) {
17
+ errors.push('missing server/package.json');
18
+ }
19
+ if (!(await fs.pathExists(serverIndex))) {
20
+ errors.push('missing server/index.js');
21
+ }
22
+ if (!(await fs.pathExists(buildDir))) {
23
+ errors.push('missing root build/ directory');
24
+ } else {
25
+ const hasEntry = (await Promise.all(ENTRY_CANDIDATES.map(name => fs.pathExists(path.join(buildDir, name))))).some(Boolean);
26
+ if (!hasEntry) {
27
+ errors.push(`build/ must contain one of: ${ENTRY_CANDIDATES.join(', ')}`);
28
+ }
29
+ }
30
+
31
+ return {
32
+ ok: errors.length === 0,
33
+ errors,
34
+ hasMigration: await hasSqlMigrations(path.join(rootDir, 'server', 'sql'))
35
+ };
36
+ };
37
+
38
+ const hasSqlMigrations = async sqlDir => {
39
+ if (!(await fs.pathExists(sqlDir))) {
40
+ return false;
41
+ }
42
+ const files = await fs.readdir(sqlDir);
43
+ return files.some(f => f.endsWith('.sql'));
44
+ };
45
+
46
+ const listSqlFiles = async sqlDir => {
47
+ if (!(await fs.pathExists(sqlDir))) {
48
+ return [];
49
+ }
50
+ const files = await fs.readdir(sqlDir);
51
+ return files.filter(f => f.endsWith('.sql')).sort();
52
+ };
53
+
54
+ module.exports = {
55
+ ENTRY_CANDIDATES,
56
+ validatePackageRoot,
57
+ hasSqlMigrations,
58
+ listSqlFiles
59
+ };
@@ -0,0 +1,77 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const { spawn } = require('node:child_process');
4
+ const { extractZipSafe, resolvePackageRoot } = require('./zip');
5
+ const { validatePackageRoot, listSqlFiles } = require('./validatePackage');
6
+
7
+ const runNpmInstall = (cwd, timeoutMs) =>
8
+ new Promise((resolve, reject) => {
9
+ const child = spawn('npm', ['install', '--production', '--no-fund', '--no-audit'], {
10
+ cwd,
11
+ env: process.env,
12
+ stdio: ['ignore', 'pipe', 'pipe']
13
+ });
14
+ let stderr = '';
15
+ child.stderr.on('data', chunk => {
16
+ stderr += chunk.toString();
17
+ });
18
+ const timer = setTimeout(() => {
19
+ child.kill('SIGKILL');
20
+ reject(new Error(`npm install timed out after ${timeoutMs}ms`));
21
+ }, timeoutMs);
22
+ child.on('error', err => {
23
+ clearTimeout(timer);
24
+ reject(err);
25
+ });
26
+ child.on('close', code => {
27
+ clearTimeout(timer);
28
+ if (code === 0) {
29
+ resolve();
30
+ } else {
31
+ reject(new Error(`npm install failed (${code}): ${stderr.slice(-2000)}`));
32
+ }
33
+ });
34
+ });
35
+
36
+ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath }) => {
37
+ await fs.emptyDir(artifactPath);
38
+ const extractDir = path.join(artifactPath, '_extract');
39
+ await extractZipSafe(zipBuffer, extractDir, { maxZipSize, maxZipEntries });
40
+ const packageRoot = await resolvePackageRoot(extractDir);
41
+
42
+ const validation = await validatePackageRoot(packageRoot);
43
+ if (!validation.ok) {
44
+ await fs.remove(artifactPath);
45
+ throw new Error(`invalid package: ${validation.errors.join('; ')}`);
46
+ }
47
+
48
+ // Move package contents to artifact root
49
+ const staging = path.join(artifactPath, '_pkg');
50
+ await fs.move(packageRoot, staging, { overwrite: true });
51
+ await fs.remove(extractDir);
52
+ const entries = await fs.readdir(staging);
53
+ for (const entry of entries) {
54
+ await fs.move(path.join(staging, entry), path.join(artifactPath, entry), { overwrite: true });
55
+ }
56
+ await fs.remove(staging);
57
+
58
+ await fs.copy(path.join(artifactPath, 'build'), path.join(artifactPath, 'server', 'build'), {
59
+ overwrite: true
60
+ });
61
+
62
+ const serverDir = path.join(artifactPath, 'server');
63
+ await runNpmInstall(serverDir, npmInstallTimeoutMs);
64
+
65
+ const sqlDir = path.join(serverDir, sqlPath || 'sql');
66
+ const sqlFiles = await listSqlFiles(sqlDir);
67
+ return {
68
+ hasMigration: sqlFiles.length > 0,
69
+ migrationPath: sqlPath || 'sql',
70
+ sqlFiles
71
+ };
72
+ };
73
+
74
+ module.exports = {
75
+ runNpmInstall,
76
+ prepareVersionArtifact
77
+ };
@@ -0,0 +1,69 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const AdmZip = require('adm-zip');
4
+
5
+ const isPathInside = (parent, child) => {
6
+ const relative = path.relative(parent, child);
7
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
8
+ };
9
+
10
+ const extractZipSafe = async (zipBuffer, destDir, { maxZipSize, maxZipEntries }) => {
11
+ if (!Buffer.isBuffer(zipBuffer)) {
12
+ throw new Error('zip content must be a Buffer');
13
+ }
14
+ if (zipBuffer.length > maxZipSize) {
15
+ throw new Error(`zip exceeds max size ${maxZipSize}`);
16
+ }
17
+
18
+ await fs.ensureDir(destDir);
19
+ const zip = new AdmZip(zipBuffer);
20
+ const entries = zip.getEntries();
21
+ if (entries.length > maxZipEntries) {
22
+ throw new Error(`zip has too many entries (${entries.length} > ${maxZipEntries})`);
23
+ }
24
+
25
+ for (const entry of entries) {
26
+ const entryName = entry.entryName.replace(/^[/\\]+/, '').replace(/\\/g, '/');
27
+ if (!entryName || entryName.includes('\0')) {
28
+ throw new Error('invalid zip entry name');
29
+ }
30
+ if (entryName.split('/').some(part => part === '..')) {
31
+ throw new Error(`zip slip detected: ${entry.entryName}`);
32
+ }
33
+ const target = path.resolve(destDir, entryName);
34
+ if (!isPathInside(path.resolve(destDir), target)) {
35
+ throw new Error(`zip slip detected: ${entry.entryName}`);
36
+ }
37
+ if (entry.isDirectory) {
38
+ await fs.ensureDir(target);
39
+ continue;
40
+ }
41
+ await fs.ensureDir(path.dirname(target));
42
+ await fs.writeFile(target, entry.getData());
43
+ }
44
+
45
+ // If zip has a single top-level folder, flatten optional? Keep as-is; callers resolve package root.
46
+ return destDir;
47
+ };
48
+
49
+ const resolvePackageRoot = async extractDir => {
50
+ const direct = path.join(extractDir, 'package.json');
51
+ if (await fs.pathExists(direct)) {
52
+ return extractDir;
53
+ }
54
+ const children = await fs.readdir(extractDir);
55
+ for (const child of children) {
56
+ const candidate = path.join(extractDir, child);
57
+ const stat = await fs.stat(candidate);
58
+ if (stat.isDirectory() && (await fs.pathExists(path.join(candidate, 'package.json')))) {
59
+ return candidate;
60
+ }
61
+ }
62
+ return extractDir;
63
+ };
64
+
65
+ module.exports = {
66
+ isPathInside,
67
+ extractZipSafe,
68
+ resolvePackageRoot
69
+ };