Skip to content

Commit 7550d5b

Browse files
committed
pyproc 실행 자산 manifest 배포 연결
1 parent ca6231d commit 7550d5b

5 files changed

Lines changed: 482 additions & 15 deletions

File tree

editor/package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

editor/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
"type": "module",
66
"scripts": {
77
"dev": "vite dev",
8-
"build": "tsc -b && vite build",
8+
"build": "tsc -b && vite build && npm run pyproc:assets",
99
"check": "tsc -b",
10+
"pyproc:assets": "node scripts/generatePyprocAssets.mjs",
1011
"preview": "vite preview"
1112
},
1213
"dependencies": {
@@ -24,7 +25,7 @@
2425
"class-variance-authority": "^0.7.1",
2526
"clsx": "^2.1.1",
2627
"lucide-react": "^0.577.0",
27-
"pyproc": "github:eddmpython/pyproc#74d30810d6ca004bed8f046a9edf0ec92d08a460",
28+
"pyproc": "github:eddmpython/pyproc#7ac859b2f09c8a3a83d2f808afb48550293f63df",
2829
"radix-ui": "^1.4.3",
2930
"react": "^19.2.1",
3031
"react-dom": "^19.2.1",
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env node
2+
// pyproc 실행 자산을 editor build 산출물에 복사하고 SRI manifest를 쓴다.
3+
// pyproc 구버전처럼 asset contract가 없으면 산출물을 만들지 않는다.
4+
import { createHash } from "node:crypto";
5+
import { existsSync } from "node:fs";
6+
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
7+
import path, { dirname, resolve, sep } from "node:path";
8+
import { fileURLToPath, pathToFileURL } from "node:url";
9+
10+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
11+
const EDITOR_ROOT = resolve(SCRIPT_DIR, "..");
12+
const DEFAULT_VENDOR_ROOT = "vendor/pyproc";
13+
const IMPORT_RE = /^\s*(?:import|export)\s+(?:[^"']*?\s+from\s+)?["'](\.{1,2}\/[^"']+)["']/gm;
14+
const DYNAMIC_IMPORT_RE = /import\(\s*["'](\.{1,2}\/[^"']+)["']\s*\)/g;
15+
const IMPORT_SCRIPTS_RE = /importScripts\(\s*["'](\.{1,2}\/[^"']+)["']\s*\)/g;
16+
17+
function parseArgs(argv) {
18+
const opts = {
19+
packageRoot: process.env.CODARO_PYPROC_PACKAGE_ROOT || resolve(EDITOR_ROOT, "node_modules", "pyproc"),
20+
outDir: process.env.CODARO_WEB_OUT || resolve(EDITOR_ROOT, "..", "src", "codaro", "webBuild"),
21+
baseURL: process.env.CODARO_PYPROC_ASSET_BASE || defaultBaseURL(),
22+
vendorRoot: DEFAULT_VENDOR_ROOT,
23+
};
24+
for (let index = 0; index < argv.length; index += 1) {
25+
const arg = argv[index];
26+
if (arg === "--package-root") {
27+
opts.packageRoot = argv[++index];
28+
continue;
29+
}
30+
if (arg === "--out-dir") {
31+
opts.outDir = argv[++index];
32+
continue;
33+
}
34+
if (arg === "--baseURL") {
35+
opts.baseURL = argv[++index];
36+
continue;
37+
}
38+
if (arg === "--vendor-root") {
39+
opts.vendorRoot = argv[++index];
40+
continue;
41+
}
42+
throw new Error(`알 수 없는 인자: ${arg}`);
43+
}
44+
return {
45+
...opts,
46+
packageRoot: resolve(opts.packageRoot),
47+
outDir: resolve(opts.outDir),
48+
vendorRoot: trimSlashes(opts.vendorRoot) || DEFAULT_VENDOR_ROOT,
49+
};
50+
}
51+
52+
function defaultBaseURL() {
53+
const webBase = trimSlashes(process.env.CODARO_WEB_BASE || "");
54+
return `/${webBase ? `${webBase}/` : ""}${DEFAULT_VENDOR_ROOT}/`;
55+
}
56+
57+
function trimSlashes(value) {
58+
return String(value || "").replace(/^\/+|\/+$/g, "");
59+
}
60+
61+
function toPosix(value) {
62+
return value.replaceAll("\\", "/");
63+
}
64+
65+
function packageRelativePath(root, absPath) {
66+
return toPosix(absPath.slice(root.length + 1));
67+
}
68+
69+
function assetAbsolutePath(root, relPath) {
70+
const absPath = resolve(root, relPath);
71+
if (!(absPath === root || absPath.startsWith(root + sep))) {
72+
throw new Error(`패키지 루트 밖 경로: ${relPath}`);
73+
}
74+
return absPath;
75+
}
76+
77+
function outputAbsolutePath(root, relPath) {
78+
const absPath = resolve(root, relPath);
79+
if (!(absPath === root || absPath.startsWith(root + sep))) {
80+
throw new Error(`출력 루트 밖 경로: ${relPath}`);
81+
}
82+
return absPath;
83+
}
84+
85+
function publicURL(root, relPath) {
86+
if (root.startsWith("/") || root.startsWith("./") || root.startsWith("../")) return root + relPath;
87+
return new URL(relPath, root).href;
88+
}
89+
90+
function sri(bytes) {
91+
return `sha256-${createHash("sha256").update(bytes).digest("base64")}`;
92+
}
93+
94+
function localSpecifiers(source) {
95+
const found = new Set();
96+
for (const regex of [IMPORT_RE, DYNAMIC_IMPORT_RE, IMPORT_SCRIPTS_RE]) {
97+
regex.lastIndex = 0;
98+
for (const match of source.matchAll(regex)) found.add(match[1]);
99+
}
100+
return [...found];
101+
}
102+
103+
function resolveLocalImport(root, fromRelPath, specifier) {
104+
if (!specifier.startsWith(".")) return null;
105+
const fromDir = dirname(assetAbsolutePath(root, fromRelPath));
106+
let absPath = resolve(fromDir, specifier);
107+
if (!/\.[a-z0-9]+$/i.test(absPath)) absPath += ".js";
108+
if (!(absPath === root || absPath.startsWith(root + sep))) {
109+
throw new Error(`import가 패키지 루트 밖을 가리킴: ${fromRelPath} -> ${specifier}`);
110+
}
111+
return packageRelativePath(root, absPath);
112+
}
113+
114+
async function loadAssetContract(root, baseURL) {
115+
const candidates = [
116+
resolve(root, "src", "runtime", "assets.js"),
117+
resolve(root, "index.js"),
118+
];
119+
for (const candidate of candidates) {
120+
if (!existsSync(candidate)) continue;
121+
const moduleUrl = pathToFileURL(candidate).href;
122+
const module = await import(moduleUrl);
123+
if (typeof module.getPyProcAssetManifest === "function") {
124+
return module.getPyProcAssetManifest({ baseURL });
125+
}
126+
}
127+
return null;
128+
}
129+
130+
async function collectGraph(root, entryRelPath) {
131+
const seen = new Set();
132+
const stack = [entryRelPath];
133+
while (stack.length) {
134+
const relPath = stack.pop();
135+
if (seen.has(relPath)) continue;
136+
const absPath = assetAbsolutePath(root, relPath);
137+
if (!existsSync(absPath)) throw new Error(`pyproc 실행 자산 파일 없음: ${relPath}`);
138+
seen.add(relPath);
139+
const source = await readFile(absPath, "utf8");
140+
for (const specifier of localSpecifiers(source)) {
141+
const dependency = resolveLocalImport(root, relPath, specifier);
142+
if (dependency && !seen.has(dependency)) stack.push(dependency);
143+
}
144+
}
145+
return [...seen].sort();
146+
}
147+
148+
async function buildManifest(root, contract) {
149+
const fileRoles = new Map();
150+
const entrypoints = [];
151+
for (const asset of contract.assets || []) {
152+
const graph = await collectGraph(root, asset.path);
153+
entrypoints.push({ ...asset, graph });
154+
for (const relPath of graph) {
155+
if (!fileRoles.has(relPath)) fileRoles.set(relPath, new Set());
156+
fileRoles.get(relPath).add(asset.role);
157+
}
158+
}
159+
160+
const files = [];
161+
for (const relPath of [...fileRoles.keys()].sort()) {
162+
const bytes = await readFile(assetAbsolutePath(root, relPath));
163+
files.push({
164+
path: relPath,
165+
url: publicURL(contract.packageRoot, relPath),
166+
bytes: bytes.byteLength,
167+
integrity: sri(bytes),
168+
roles: [...fileRoles.get(relPath)].sort(),
169+
});
170+
}
171+
172+
const filesByPath = new Map(files.map((file) => [file.path, file]));
173+
for (const entrypoint of entrypoints) {
174+
const file = filesByPath.get(entrypoint.path);
175+
entrypoint.integrity = file.integrity;
176+
entrypoint.bytes = file.bytes;
177+
}
178+
179+
return {
180+
version: contract.version,
181+
packageRoot: contract.packageRoot,
182+
policy: contract.policy,
183+
entrypoints,
184+
files,
185+
};
186+
}
187+
188+
async function clearOutputs(outDir, vendorRoot) {
189+
await rm(outputAbsolutePath(outDir, "pyproc-assets.json"), { force: true });
190+
await rm(outputAbsolutePath(outDir, vendorRoot), { recursive: true, force: true });
191+
}
192+
193+
async function copyFiles(packageRoot, outDir, vendorRoot, files) {
194+
await rm(outputAbsolutePath(outDir, vendorRoot), { recursive: true, force: true });
195+
for (const file of files) {
196+
const dest = outputAbsolutePath(outDir, path.join(vendorRoot, file.path));
197+
await mkdir(dirname(dest), { recursive: true });
198+
await copyFile(assetAbsolutePath(packageRoot, file.path), dest);
199+
}
200+
}
201+
202+
async function main() {
203+
const opts = parseArgs(process.argv.slice(2));
204+
await mkdir(opts.outDir, { recursive: true });
205+
const contract = await loadAssetContract(opts.packageRoot, opts.baseURL);
206+
if (!contract) {
207+
await clearOutputs(opts.outDir, opts.vendorRoot);
208+
console.warn("pyproc asset contract not found; skipped pyproc asset manifest generation");
209+
return;
210+
}
211+
212+
const manifest = await buildManifest(opts.packageRoot, contract);
213+
await copyFiles(opts.packageRoot, opts.outDir, opts.vendorRoot, manifest.files);
214+
await writeFile(
215+
outputAbsolutePath(opts.outDir, "pyproc-assets.json"),
216+
`${JSON.stringify(manifest, null, 2)}\n`,
217+
"utf8",
218+
);
219+
console.log(`pyproc assets: ${manifest.files.length} files -> ${toPosix(opts.outDir)}`);
220+
}
221+
222+
main().catch((error) => {
223+
console.error(error && error.stack ? error.stack : String(error));
224+
process.exit(1);
225+
});

editor/src/lib/browserPythonRuntime.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,55 @@ type PyRuntime = {
1313
loadPackages(pkgs: string | string[]): Promise<void>;
1414
};
1515

16+
type PyProcAssetIntegrity = {
17+
files?: { path: string; url: string; integrity: string; roles?: string[] }[];
18+
};
19+
20+
type PyProcModule = {
21+
boot(opts: {
22+
stdout?: (line: string) => void;
23+
stderr?: (line: string) => void;
24+
assetIntegrity?: PyProcAssetIntegrity;
25+
}): Promise<PyRuntime>;
26+
};
27+
1628
let runtimePromise: Promise<PyRuntime> | null = null;
29+
let assetIntegrityPromise: Promise<PyProcAssetIntegrity | null> | null = null;
1730
const stdoutLines: string[] = [];
1831
const stderrLines: string[] = [];
1932
let previousVariables = new Map<string, VariableInfo>();
2033
const attemptedPackages = new Set<string>();
2134

35+
function assetIntegrityUrl(): string {
36+
const envUrl = import.meta.env.VITE_PYPROC_ASSET_INTEGRITY_URL;
37+
if (typeof envUrl === "string" && envUrl.trim()) return envUrl;
38+
return new URL("pyproc-assets.json", import.meta.env.BASE_URL || "/").pathname;
39+
}
40+
41+
async function loadAssetIntegrity(): Promise<PyProcAssetIntegrity | null> {
42+
if (!assetIntegrityPromise) {
43+
assetIntegrityPromise = fetch(assetIntegrityUrl(), { cache: "no-store", credentials: "same-origin" })
44+
.then((response) => (response.ok ? response.json() as Promise<PyProcAssetIntegrity> : null))
45+
.catch((error: unknown) => {
46+
console.warn("pyproc asset manifest unavailable", error);
47+
return null;
48+
});
49+
}
50+
return assetIntegrityPromise;
51+
}
52+
2253
async function ensureRuntime(): Promise<PyRuntime> {
2354
if (!runtimePromise) {
2455
runtimePromise = import("pyproc")
25-
.then(({ boot }) =>
26-
boot({
56+
.then(async (module) => {
57+
const { boot } = module as unknown as PyProcModule;
58+
const assetIntegrity = await loadAssetIntegrity();
59+
return boot({
2760
stdout: (line: string) => stdoutLines.push(line),
2861
stderr: (line: string) => stderrLines.push(line),
29-
}),
30-
)
62+
...(assetIntegrity ? { assetIntegrity } : {}),
63+
});
64+
})
3165
.catch((error: unknown) => {
3266
runtimePromise = null;
3367
throw error;

0 commit comments

Comments
 (0)