@vwork/function-bundler 0.1.0

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,22 @@
1
+ import type { FunctionProjectSnapshot } from "./project.js";
2
+ export interface BundleFunctionProjectInput {
3
+ readonly snapshot: FunctionProjectSnapshot;
4
+ readonly workspaceRoot: string;
5
+ readonly installDependencies?: boolean;
6
+ readonly maxBundleBytes?: number;
7
+ readonly installTimeoutMs?: number;
8
+ }
9
+ export interface BundleFunctionProjectResult {
10
+ readonly code: string;
11
+ readonly codeHash: string;
12
+ readonly codeSize: number;
13
+ readonly manifest: {
14
+ readonly entrypoint: string;
15
+ readonly source_tree_hash: string;
16
+ readonly source_file_count: number;
17
+ readonly source_size_bytes: number;
18
+ readonly builder: "vwork-esbuild";
19
+ };
20
+ readonly log: string;
21
+ }
22
+ export declare function bundleFunctionProject(input: BundleFunctionProjectInput): Promise<BundleFunctionProjectResult>;
package/dist/bundle.js ADDED
@@ -0,0 +1,119 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { createRequire } from "node:module";
6
+ import { promisify } from "node:util";
7
+ import { build } from "esbuild";
8
+ import { FUNCTION_PROJECT_LIMITS } from "./project.js";
9
+ const execFileAsync = promisify(execFile);
10
+ const require = createRequire(import.meta.url);
11
+ export async function bundleFunctionProject(input) {
12
+ await writeSnapshot(input.workspaceRoot, input.snapshot);
13
+ let log = "";
14
+ if (input.installDependencies && hasPackageJson(input.snapshot)) {
15
+ log += await installSnapshotDependencies(input.workspaceRoot, input.installTimeoutMs);
16
+ }
17
+ const output = await build({
18
+ entryPoints: [join(input.workspaceRoot, input.snapshot.entrypoint)],
19
+ bundle: true,
20
+ write: false,
21
+ format: "esm",
22
+ platform: "browser",
23
+ target: "es2022",
24
+ mainFields: ["browser", "module", "main"],
25
+ conditions: ["worker", "browser", "import", "default"],
26
+ logLevel: "silent"
27
+ }).catch((error) => {
28
+ throw new Error(`BUNDLE_FAILED: ${error instanceof Error ? error.message : String(error)}`);
29
+ });
30
+ const code = normalizeDefaultExport(output.outputFiles[0]?.text ?? "");
31
+ validateWorkerOutput(code);
32
+ const codeSize = Buffer.byteLength(code, "utf8");
33
+ const maxBundleBytes = input.maxBundleBytes ?? FUNCTION_PROJECT_LIMITS.maxBundleBytes;
34
+ if (codeSize > maxBundleBytes) {
35
+ throw new Error(`BUNDLE_TOO_LARGE: ${codeSize}`);
36
+ }
37
+ return {
38
+ code,
39
+ codeHash: `sha256:${createHash("sha256").update(code).digest("hex")}`,
40
+ codeSize,
41
+ manifest: {
42
+ entrypoint: input.snapshot.entrypoint,
43
+ source_tree_hash: input.snapshot.treeHash,
44
+ source_file_count: input.snapshot.sourceFileCount,
45
+ source_size_bytes: input.snapshot.sourceSizeBytes,
46
+ builder: "vwork-esbuild"
47
+ },
48
+ log: `${log}Bundled ${input.snapshot.entrypoint} into dist/index.js (${codeSize} bytes)\n`
49
+ };
50
+ }
51
+ async function writeSnapshot(root, snapshot) {
52
+ for (const file of snapshot.files) {
53
+ const target = join(root, file.path);
54
+ await mkdir(dirname(target), { recursive: true });
55
+ await writeFile(target, file.content, "utf8");
56
+ }
57
+ }
58
+ function validateWorkerOutput(code) {
59
+ if (!/\bexport\s+default\b/.test(code) && !/\bexport\s*\{[^}]*\bdefault\b[^}]*\}/.test(code)) {
60
+ throw new Error("INVALID_WORKER_OUTPUT: missing default export");
61
+ }
62
+ if (!/\bfetch\s*\(/.test(code)) {
63
+ throw new Error("INVALID_WORKER_OUTPUT: missing fetch handler");
64
+ }
65
+ const executableCode = stripComments(code);
66
+ if (/\beval\s*\(|new\s+Function\s*\(/.test(executableCode)) {
67
+ throw new Error("INVALID_WORKER_OUTPUT: dynamic execution API found");
68
+ }
69
+ if (/\brequire\s*\(/.test(executableCode)) {
70
+ throw new Error("INVALID_WORKER_OUTPUT: CommonJS require call found");
71
+ }
72
+ }
73
+ function stripComments(code) {
74
+ return code
75
+ .replace(/\/\*[\s\S]*?\*\//g, "")
76
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
77
+ }
78
+ function normalizeDefaultExport(code) {
79
+ return code.replace(/export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*\};?\s*$/m, "export default $1;\n");
80
+ }
81
+ function hasPackageJson(snapshot) {
82
+ return snapshot.files.some((file) => file.path === "package.json");
83
+ }
84
+ async function installSnapshotDependencies(workspaceRoot, timeoutMs) {
85
+ const pnpmBin = join(dirname(require.resolve("pnpm")), "bin", "pnpm.cjs");
86
+ try {
87
+ const { stdout, stderr } = await execFileAsync(process.execPath, [pnpmBin, "install", "--ignore-scripts"], {
88
+ cwd: workspaceRoot,
89
+ maxBuffer: 10 * 1024 * 1024,
90
+ ...(timeoutMs ? { timeout: timeoutMs } : {})
91
+ });
92
+ const output = summarizeInstallOutput(stdout, stderr);
93
+ return output
94
+ ? `Ran pnpm install --ignore-scripts in snapshot workspace\n${output}\n`
95
+ : "Ran pnpm install --ignore-scripts in snapshot workspace\n";
96
+ }
97
+ catch (error) {
98
+ const output = summarizeInstallError(error);
99
+ const detail = output ? ` ${output}` : "";
100
+ throw new Error(`DEPENDENCY_INSTALL_FAILED:${detail}`);
101
+ }
102
+ }
103
+ function summarizeInstallOutput(stdout, stderr) {
104
+ const output = `${stdout}\n${stderr}`.trim();
105
+ if (!output) {
106
+ return "";
107
+ }
108
+ return output.split(/\r?\n/).slice(-10).join("\n");
109
+ }
110
+ function summarizeInstallError(error) {
111
+ if (!error || typeof error !== "object") {
112
+ return String(error);
113
+ }
114
+ const stdout = "stdout" in error && typeof error.stdout === "string" ? error.stdout : "";
115
+ const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr : "";
116
+ const message = "message" in error && typeof error.message === "string" ? error.message : "";
117
+ const output = summarizeInstallOutput(stdout, stderr);
118
+ return output || message;
119
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./project.js";
2
+ export * from "./bundle.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./project.js";
2
+ export * from "./bundle.js";
@@ -0,0 +1,28 @@
1
+ export interface FunctionProjectFile {
2
+ readonly path: string;
3
+ readonly language: string;
4
+ readonly content: string;
5
+ readonly sizeBytes: number;
6
+ }
7
+ export interface FunctionProjectSnapshot {
8
+ readonly files: readonly FunctionProjectFile[];
9
+ readonly entrypoint: "src/index.ts" | "src/index.js";
10
+ readonly sourceFileCount: number;
11
+ readonly sourceSizeBytes: number;
12
+ readonly treeHash: string;
13
+ }
14
+ export interface FunctionProjectLimits {
15
+ readonly maxFiles: number;
16
+ readonly maxFileBytes: number;
17
+ readonly maxTotalBytes: number;
18
+ readonly maxBundleBytes: number;
19
+ readonly maxDependencies: number;
20
+ }
21
+ export interface CollectFunctionProjectOptions {
22
+ readonly include?: readonly string[];
23
+ readonly limits?: Partial<FunctionProjectLimits>;
24
+ }
25
+ export declare const FUNCTION_PROJECT_LIMITS: FunctionProjectLimits;
26
+ export declare function validateSourcePath(path: string): string;
27
+ export declare function collectFunctionProject(root: string, options?: CollectFunctionProjectOptions): Promise<FunctionProjectSnapshot>;
28
+ export declare function resolveEntrypoint(files: readonly FunctionProjectFile[]): "src/index.ts" | "src/index.js";
@@ -0,0 +1,172 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+ export const FUNCTION_PROJECT_LIMITS = {
5
+ maxFiles: 100,
6
+ maxFileBytes: 256 * 1024,
7
+ maxTotalBytes: 2 * 1024 * 1024,
8
+ maxBundleBytes: 5 * 1024 * 1024,
9
+ maxDependencies: 50
10
+ };
11
+ const defaultRootFiles = new Set(["package.json", "pnpm-lock.yaml", "tsconfig.json", "README.md"]);
12
+ const excludedPrefixes = ["node_modules/", "dist/", ".git/", "coverage/", ".tmp/", ".cache/"];
13
+ const dependencySections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
14
+ export function validateSourcePath(path) {
15
+ const normalized = normalizeSourcePath(path);
16
+ if (isUnsafePath(normalized, path)) {
17
+ throw new Error(`INVALID_SOURCE_PATH: ${path}`);
18
+ }
19
+ if (isAlwaysExcludedPath(normalized) || isHiddenPath(normalized)) {
20
+ throw new Error(`INVALID_SOURCE_PATH: ${normalized}`);
21
+ }
22
+ return normalized;
23
+ }
24
+ export async function collectFunctionProject(root, options = {}) {
25
+ const limits = { ...FUNCTION_PROJECT_LIMITS, ...options.limits };
26
+ const files = [];
27
+ await walk(root, root, files, options.include ?? []);
28
+ files.sort((a, b) => a.path.localeCompare(b.path));
29
+ if (files.length > limits.maxFiles) {
30
+ throw new Error(`SOURCE_FILE_COUNT_EXCEEDED: ${files.length}`);
31
+ }
32
+ const total = files.reduce((sum, file) => sum + file.sizeBytes, 0);
33
+ if (total > limits.maxTotalBytes) {
34
+ throw new Error(`SOURCE_TOO_LARGE: ${total}`);
35
+ }
36
+ for (const file of files) {
37
+ if (file.sizeBytes > limits.maxFileBytes) {
38
+ throw new Error(`SOURCE_FILE_TOO_LARGE: ${file.path}`);
39
+ }
40
+ }
41
+ const dependencyCount = countProjectDependencies(files);
42
+ if (dependencyCount > limits.maxDependencies) {
43
+ throw new Error(`DEPENDENCY_COUNT_EXCEEDED: ${dependencyCount}`);
44
+ }
45
+ return {
46
+ files,
47
+ entrypoint: resolveEntrypoint(files),
48
+ sourceFileCount: files.length,
49
+ sourceSizeBytes: total,
50
+ treeHash: hashFiles(files)
51
+ };
52
+ }
53
+ export function resolveEntrypoint(files) {
54
+ const paths = new Set(files.map((file) => file.path));
55
+ if (paths.has("src/index.ts")) {
56
+ return "src/index.ts";
57
+ }
58
+ if (paths.has("src/index.js")) {
59
+ return "src/index.js";
60
+ }
61
+ throw new Error("MISSING_FUNCTION_ENTRYPOINT: expected src/index.ts or src/index.js");
62
+ }
63
+ async function walk(root, directory, output, include) {
64
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
65
+ const absolute = join(directory, entry.name);
66
+ const relativePath = normalizeSourcePath(relative(root, absolute).split(sep).join("/"));
67
+ if (isUnsafePath(relativePath, relativePath) || isAlwaysExcludedPath(relativePath)) {
68
+ continue;
69
+ }
70
+ const explicitlyIncluded = include.some((pattern) => simpleGlobMatch(pattern, relativePath));
71
+ if (isHiddenPath(relativePath) && !explicitlyIncluded) {
72
+ continue;
73
+ }
74
+ if (entry.isDirectory()) {
75
+ if (shouldIncludeDirectory(relativePath, include)) {
76
+ await walk(root, absolute, output, include);
77
+ }
78
+ continue;
79
+ }
80
+ if (!entry.isFile() || !shouldIncludeFile(relativePath, include)) {
81
+ continue;
82
+ }
83
+ const content = await readFile(absolute, "utf8");
84
+ const sizeBytes = (await stat(absolute)).size;
85
+ output.push({ path: relativePath, language: languageForPath(relativePath), content, sizeBytes });
86
+ }
87
+ }
88
+ function normalizeSourcePath(path) {
89
+ return path.replaceAll("\\", "/").replace(/^\/+/, "");
90
+ }
91
+ function isUnsafePath(normalized, originalPath) {
92
+ return (!normalized ||
93
+ normalized.includes("\0") ||
94
+ normalized.startsWith("../") ||
95
+ normalized.includes("/../") ||
96
+ normalized === ".." ||
97
+ /^[a-zA-Z]:/.test(originalPath));
98
+ }
99
+ function isAlwaysExcludedPath(path) {
100
+ return path === ".env" || path.startsWith(".env.") || path.endsWith(".log") || excludedPrefixes.some((prefix) => path.startsWith(prefix));
101
+ }
102
+ function isHiddenPath(path) {
103
+ return path.split("/").some((part) => part.startsWith(".") && part !== ".");
104
+ }
105
+ function shouldIncludeDirectory(path, include) {
106
+ return (path === "src" ||
107
+ path.startsWith("src/") ||
108
+ include.some((pattern) => {
109
+ const prefix = globPrefix(pattern);
110
+ return prefix !== "" && (path === prefix || path.startsWith(`${prefix}/`));
111
+ }));
112
+ }
113
+ function shouldIncludeFile(path, include) {
114
+ return path.startsWith("src/") || defaultRootFiles.has(path) || include.some((pattern) => simpleGlobMatch(pattern, path));
115
+ }
116
+ function globPrefix(pattern) {
117
+ const index = pattern.indexOf("*");
118
+ const prefix = index >= 0 ? pattern.slice(0, index) : pattern;
119
+ return prefix.replace(/\/+$/, "");
120
+ }
121
+ function simpleGlobMatch(pattern, path) {
122
+ if (pattern.endsWith("/**")) {
123
+ return path.startsWith(pattern.slice(0, -3));
124
+ }
125
+ if (pattern.includes("*")) {
126
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*");
127
+ return new RegExp(`^${escaped}$`).test(path);
128
+ }
129
+ return pattern === path;
130
+ }
131
+ function languageForPath(path) {
132
+ if (path.endsWith(".ts")) {
133
+ return "typescript";
134
+ }
135
+ if (path.endsWith(".js")) {
136
+ return "javascript";
137
+ }
138
+ if (path.endsWith(".json")) {
139
+ return "json";
140
+ }
141
+ if (path.endsWith(".md")) {
142
+ return "markdown";
143
+ }
144
+ if (path.endsWith(".yaml") || path.endsWith(".yml")) {
145
+ return "yaml";
146
+ }
147
+ return "text";
148
+ }
149
+ function countProjectDependencies(files) {
150
+ const packageJsonFile = files.find((file) => file.path === "package.json");
151
+ if (!packageJsonFile) {
152
+ return 0;
153
+ }
154
+ const packageJson = JSON.parse(packageJsonFile.content);
155
+ return dependencySections.reduce((count, section) => {
156
+ const value = packageJson[section];
157
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
158
+ return count;
159
+ }
160
+ return count + Object.keys(value).length;
161
+ }, 0);
162
+ }
163
+ function hashFiles(files) {
164
+ const hash = createHash("sha256");
165
+ for (const file of files) {
166
+ hash.update(file.path);
167
+ hash.update("\0");
168
+ hash.update(file.content);
169
+ hash.update("\0");
170
+ }
171
+ return `sha256:${hash.digest("hex")}`;
172
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@vwork/function-bundler",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "dist/*.js",
7
+ "dist/*.d.ts",
8
+ "package.json"
9
+ ],
10
+ "publishConfig": {
11
+ "registry": "https://registry.npmjs.org/",
12
+ "access": "public"
13
+ },
14
+ "exports": {
15
+ ".": "./dist/index.js"
16
+ },
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "dependencies": {
20
+ "esbuild": "^0.25.5",
21
+ "pnpm": "10.18.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.10.2",
25
+ "tsx": "^4.19.2",
26
+ "typescript": "^5.9.3"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "test": "tsx --test src/test/*.test.ts",
31
+ "typecheck": "tsc -p tsconfig.json --noEmit"
32
+ }
33
+ }