Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ jobs:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
Expand Down
58 changes: 56 additions & 2 deletions apps/web/src/pages/DownloadsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { useState, useEffect } from 'react';
import { Upload, Download, BookOpen, ChevronDown, ChevronRight, AlertTriangle, CheckCircle, Package } from 'lucide-react';
import { Upload, Download, BookOpen, ChevronDown, ChevronRight, AlertTriangle, CheckCircle, Package, FileCode2 } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { API_BASE } from '@/lib/constants';
import { useAccountsStore, type Account } from '@/stores/accounts';
import { useAuthStore } from '@/stores/auth';

/** Server-side ZIP filenames, keyed by EA card type. */
const SOURCE_ZIP_NAMES: Record<'master' | 'follower' | 'journal', string> = {
master: 'EdgeRelay_Master_Source.zip',
follower: 'EdgeRelay_Follower_Source.zip',
journal: 'TradeJournal_Sync_Source.zip',
};

/* ------------------------------------------------------------------ */
/* Setup Guide Data */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -286,6 +294,7 @@ function EADownloadCard({
accounts: Account[];
}) {
const [downloading, setDownloading] = useState(false);
const [sourceDownloading, setSourceDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const token = useAuthStore((s) => s.token);

Expand Down Expand Up @@ -341,6 +350,40 @@ function EADownloadCard({
}
};

// Source ZIP is generic (no per-user credentials) — available to any
// signed-in user without needing a matching account created first.
const handleSourceDownload = async () => {
setError(null);
setSourceDownloading(true);

try {
const res = await fetch(`${API_BASE}/accounts/ea-source/${type}`, {
headers: { Authorization: `Bearer ${token}` },
});

if (!res.ok) {
const json = await res.json().catch(() => null);
const msg = (json as { error?: { message?: string } })?.error?.message || 'Source download failed';
setError(msg);
return;
}

const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = SOURCE_ZIP_NAMES[type];
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
setError('Network error. Please try again.');
} finally {
setSourceDownloading(false);
}
};

return (
<Card hover className="flex flex-col">
<div className="flex items-start gap-4">
Expand All @@ -366,7 +409,7 @@ function EADownloadCard({
<span className="text-xs text-slate-500 font-mono-nums">{isJournal ? '~38 KB' : isMaster ? '~45 KB' : '~52 KB'}</span>
</div>

<div className="mt-4">
<div className="mt-4 space-y-2">
<Button
variant={isMaster || isJournal ? 'primary' : 'secondary'}
size="md"
Expand All @@ -377,6 +420,17 @@ function EADownloadCard({
<Download className="h-4 w-4" />
Download .ex5
</Button>
<Button
variant="ghost"
size="md"
isLoading={sourceDownloading}
onClick={handleSourceDownload}
className="w-full"
title="Full MQL5 source + required include files, ready to compile in MetaEditor"
>
<FileCode2 className="h-4 w-4" />
Source (.mq5)
</Button>
</div>

{error && (
Expand Down
4 changes: 4 additions & 0 deletions workers/api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"embed:ea": "node scripts/embed-ea-source.mjs",
"prebuild": "npm run embed:ea",
"predev": "npm run embed:ea",
"predeploy": "npm run embed:ea",
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit"
Expand Down
65 changes: 65 additions & 0 deletions workers/api-gateway/scripts/embed-ea-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// embed-ea-source.mjs
// ---------------------------------------------------------------------------
// Reads the canonical MQL5 source files from apps/ea/ and generates a bundled
// TypeScript module the Worker can serve at runtime (Cloudflare Workers cannot
// read the filesystem, so the source is embedded at build time).
//
// The single source of truth stays the real .mq5 / .mqh files — run this
// whenever they change (wired into `prebuild` / predeploy in package.json).
//
// node scripts/embed-ea-source.mjs
// ---------------------------------------------------------------------------

import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const EA_DIR = join(__dirname, '..', '..', '..', 'apps', 'ea');
const INCLUDE_DIR = join(EA_DIR, 'Include');
const OUT_FILE = join(__dirname, '..', 'src', 'generated', 'ea-source.ts');

// The three top-level Expert Advisors (Scripts/ setup EA is bundled separately).
const EA_FILES = ['EdgeRelay_Master.mq5', 'EdgeRelay_Follower.mq5', 'TradeJournal_Sync.mq5'];

/** Collect { basename: contents } for a fixed list of EA files + every .mqh include. */
function collectSources() {
const sources = {};

for (const file of EA_FILES) {
sources[file] = readFileSync(join(EA_DIR, file), 'utf8');
}

for (const file of readdirSync(INCLUDE_DIR)) {
if (file.endsWith('.mqh')) {
sources[basename(file)] = readFileSync(join(INCLUDE_DIR, file), 'utf8');
}
}

return sources;
}

function generate() {
const sources = collectSources();
const keys = Object.keys(sources).sort();

const entries = keys
.map((key) => ` ${JSON.stringify(key)}: ${JSON.stringify(sources[key])},`)
.join('\n');

const banner = `// AUTO-GENERATED by scripts/embed-ea-source.mjs — DO NOT EDIT BY HAND.\n` +
`// Regenerate with: npm run embed:ea\n` +
`// Source of truth: apps/ea/**/*.mq5 and apps/ea/Include/**/*.mqh\n`;

const body =
`${banner}\n` +
`/** Canonical MQL5 source, keyed by file basename (EAs + include headers). */\n` +
`export const EA_SOURCE: Record<string, string> = {\n${entries}\n};\n`;

mkdirSync(dirname(OUT_FILE), { recursive: true });
writeFileSync(OUT_FILE, body, 'utf8');

console.log(`[embed-ea-source] wrote ${keys.length} files to ${OUT_FILE}`);
}

generate();
21 changes: 21 additions & 0 deletions workers/api-gateway/src/generated/ea-source.ts

Large diffs are not rendered by default.

141 changes: 141 additions & 0 deletions workers/api-gateway/src/lib/zip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// zip.ts
// ---------------------------------------------------------------------------
// Minimal, dependency-free ZIP writer for the Cloudflare Workers runtime.
//
// Uses the STORE method (no compression, method 0) — the files we bundle are
// small MQL5 text sources (tens of KB), so compression buys nothing and STORE
// keeps the implementation tiny and fully auditable. No `node:zlib` needed.
//
// Produces a spec-compliant ZIP: for each entry a local file header + data,
// followed by the central directory and end-of-central-directory record.
// ---------------------------------------------------------------------------

export interface ZipEntry {
/** Path within the archive, e.g. "Experts/EdgeRelay_Master.mq5". Use forward slashes. */
name: string;
/** File contents. */
data: string | Uint8Array;
}

// Precomputed CRC-32 lookup table (IEEE 802.3 polynomial).
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c >>> 0;
}
return table;
})();

function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (let i = 0; i < bytes.length; i++) {
crc = CRC_TABLE[(crc ^ bytes[i]!) & 0xff]! ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}

/** DOS date/time. We use a fixed timestamp so archives are deterministic. */
const DOS_TIME = 0; // 00:00:00
const DOS_DATE = ((2024 - 1980) << 9) | (1 << 5) | 1; // 2024-01-01

/**
* Build a ZIP archive from the given entries.
* @returns the raw archive bytes.
*/
export function createZip(entries: ZipEntry[]): Uint8Array {
const encoder = new TextEncoder();

interface Prepared {
nameBytes: Uint8Array;
dataBytes: Uint8Array;
crc: number;
offset: number;
}

const prepared: Prepared[] = [];
const localParts: Uint8Array[] = [];
let offset = 0;

// ── Local file headers + file data ──
for (const entry of entries) {
const nameBytes = encoder.encode(entry.name);
const dataBytes = typeof entry.data === 'string' ? encoder.encode(entry.data) : entry.data;
const crc = crc32(dataBytes);

const header = new Uint8Array(30 + nameBytes.length);
const view = new DataView(header.buffer);
view.setUint32(0, 0x04034b50, true); // local file header signature
view.setUint16(4, 20, true); // version needed to extract (2.0)
view.setUint16(6, 0, true); // general purpose flag
view.setUint16(8, 0, true); // compression method: STORE
view.setUint16(10, DOS_TIME, true);
view.setUint16(12, DOS_DATE, true);
view.setUint32(14, crc, true);
view.setUint32(18, dataBytes.length, true); // compressed size
view.setUint32(22, dataBytes.length, true); // uncompressed size
view.setUint16(26, nameBytes.length, true);
view.setUint16(28, 0, true); // extra field length
header.set(nameBytes, 30);

prepared.push({ nameBytes, dataBytes, crc, offset });
localParts.push(header, dataBytes);
offset += header.length + dataBytes.length;
}

// ── Central directory ──
const centralParts: Uint8Array[] = [];
const centralStart = offset;
let centralSize = 0;

for (const p of prepared) {
const record = new Uint8Array(46 + p.nameBytes.length);
const view = new DataView(record.buffer);
view.setUint32(0, 0x02014b50, true); // central directory header signature
view.setUint16(4, 20, true); // version made by
view.setUint16(6, 20, true); // version needed to extract
view.setUint16(8, 0, true); // general purpose flag
view.setUint16(10, 0, true); // compression method: STORE
view.setUint16(12, DOS_TIME, true);
view.setUint16(14, DOS_DATE, true);
view.setUint32(16, p.crc, true);
view.setUint32(20, p.dataBytes.length, true); // compressed size
view.setUint32(24, p.dataBytes.length, true); // uncompressed size
view.setUint16(28, p.nameBytes.length, true);
view.setUint16(30, 0, true); // extra field length
view.setUint16(32, 0, true); // file comment length
view.setUint16(34, 0, true); // disk number start
view.setUint16(36, 0, true); // internal file attributes
view.setUint32(38, 0, true); // external file attributes
view.setUint32(42, p.offset, true); // relative offset of local header
record.set(p.nameBytes, 46);

centralParts.push(record);
centralSize += record.length;
}

// ── End of central directory record ──
const eocd = new Uint8Array(22);
const eocdView = new DataView(eocd.buffer);
eocdView.setUint32(0, 0x06054b50, true); // EOCD signature
eocdView.setUint16(4, 0, true); // number of this disk
eocdView.setUint16(6, 0, true); // disk where central directory starts
eocdView.setUint16(8, prepared.length, true); // central dir records on this disk
eocdView.setUint16(10, prepared.length, true); // total central dir records
eocdView.setUint32(12, centralSize, true); // size of central directory
eocdView.setUint32(16, centralStart, true); // offset of central directory
eocdView.setUint16(20, 0, true); // comment length

// ── Concatenate everything ──
const total = offset + centralSize + eocd.length;
const out = new Uint8Array(total);
let cursor = 0;
for (const part of [...localParts, ...centralParts, eocd]) {
out.set(part, cursor);
cursor += part.length;
}
return out;
}
Loading
Loading