create-nodejs-fn 0.0.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.
- package/README.md +289 -0
- package/dist/chunk-XP4XDEWF.mjs +17 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +356 -0
- package/dist/cli.mjs +455 -0
- package/dist/index.d.mts +36 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +1013 -0
- package/dist/index.mjs +970 -0
- package/package.json +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Create NodeJS Fn
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
> **⚡ A crazy Vite plugin that lets you transparently call Node.js native code from Cloudflare Workers**
|
|
5
|
+
|
|
6
|
+
**🚨 WARNING: This project uses INSANE black magic! DO NOT use in production!! 🚨**
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## 🤯 What is this?
|
|
10
|
+
|
|
11
|
+
Cloudflare Workers are amazing, but they run on the V8 JavaScript engine—**not Node.js**. This means native modules (binary addons compiled with node-gyp) simply don't work. Want to use `@napi-rs/canvas` for image generation, `sharp` for image processing, or `pdfjs-dist` with canvas rendering? You're out of luck...
|
|
12
|
+
|
|
13
|
+
**...or are you?** 🔥
|
|
14
|
+
|
|
15
|
+
`create-nodejs-fn` bridges this gap by leveraging **Cloudflare Containers** (currently in beta). Here's how it works:
|
|
16
|
+
|
|
17
|
+
1. **You write functions in `*.container.ts` files** using any Node.js native modules you want
|
|
18
|
+
2. **The Vite plugin analyzes your code** using `ts-morph` (TypeScript AST manipulation)
|
|
19
|
+
3. **It auto-generates type-safe proxy functions** that look identical to your original exports
|
|
20
|
+
4. **Your container code is bundled with esbuild** and packaged into a Docker image
|
|
21
|
+
5. **At runtime, the proxy transparently routes calls** via Cap'n Proto RPC to the container
|
|
22
|
+
6. **Cloudflare Durable Objects manage container lifecycle** and connection state
|
|
23
|
+
|
|
24
|
+
The result? You `import { myFunction } from "./native.container"` and call it like any normal function—but it actually executes inside a Docker container running full Node.js with native module support!
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
## 🎮 Live Demo
|
|
29
|
+
|
|
30
|
+
**Try it now!** This example uses `@napi-rs/canvas` + `pdfjs-dist` to render PDF pages as images:
|
|
31
|
+
|
|
32
|
+
👉 **[Render Bitcoin Whitepaper (Page 1)](https://example-create-nodejs-fn.inaridiy.workers.dev/renderPdf?url=https://bitcoin.org/bitcoin.pdf&pageNum=1&scale=3)**
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
https://example-create-nodejs-fn.inaridiy.workers.dev/renderPdf?url=https://bitcoin.org/bitcoin.pdf&pageNum=1&scale=3
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Yes, this is running on Cloudflare Workers. Yes, it's using native Node.js modules. Yes, it's black magic.
|
|
39
|
+
|
|
40
|
+
## 🚀 Quick Start
|
|
41
|
+
|
|
42
|
+
### Prerequisites
|
|
43
|
+
|
|
44
|
+
You need a **Cloudflare Workers + Vite** project. Create one with:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Using Hono (recommended)
|
|
48
|
+
pnpm create hono@latest my-app --template cloudflare-workers+vite
|
|
49
|
+
|
|
50
|
+
# Then cd into it
|
|
51
|
+
cd my-app
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 1. Install dependencies
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pnpm add create-nodejs-fn @cloudflare/containers capnweb@0.2.0 @napi-rs/canvas
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 2. Initialize config
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pnpm create-nodejs-fn init
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This configures:
|
|
67
|
+
- Adds Containers & Durable Objects config to `wrangler.jsonc`
|
|
68
|
+
- Generates `.create-nodejs-fn/Dockerfile`
|
|
69
|
+
- Creates `src/__generated__/` directory
|
|
70
|
+
- Adds DO export to entry file
|
|
71
|
+
|
|
72
|
+
### 3. Configure Vite plugin
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// vite.config.ts
|
|
76
|
+
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
77
|
+
import { defineConfig } from "vite";
|
|
78
|
+
import { createNodejsFnPlugin } from "create-nodejs-fn";
|
|
79
|
+
|
|
80
|
+
export default defineConfig({
|
|
81
|
+
plugins: [
|
|
82
|
+
createNodejsFnPlugin({
|
|
83
|
+
// Native dependencies to install in the container
|
|
84
|
+
external: ["@napi-rs/canvas"],
|
|
85
|
+
// Docker config with fonts for text rendering
|
|
86
|
+
docker: {
|
|
87
|
+
baseImage: "node:20-bookworm-slim",
|
|
88
|
+
systemPackages: [
|
|
89
|
+
"fontconfig",
|
|
90
|
+
"fonts-noto-core",
|
|
91
|
+
"fonts-noto-cjk",
|
|
92
|
+
"fonts-noto-color-emoji",
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
cloudflare(),
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 4. Write a container function
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// src/clock.container.ts
|
|
105
|
+
import { createCanvas } from "@napi-rs/canvas";
|
|
106
|
+
import { nodejsFn } from "./__generated__/create-nodejs-fn.runtime";
|
|
107
|
+
|
|
108
|
+
export const renderClock = nodejsFn(async () => {
|
|
109
|
+
// 🎨 Create an image with current time using @napi-rs/canvas!
|
|
110
|
+
const canvas = createCanvas(600, 200);
|
|
111
|
+
const ctx = canvas.getContext("2d");
|
|
112
|
+
|
|
113
|
+
// Background
|
|
114
|
+
ctx.fillStyle = "#1a1a2e";
|
|
115
|
+
ctx.fillRect(0, 0, 600, 200);
|
|
116
|
+
|
|
117
|
+
// Text with Noto font (installed via systemPackages)
|
|
118
|
+
ctx.font = "bold 36px 'Noto Sans CJK JP', 'Noto Color Emoji', sans-serif";
|
|
119
|
+
ctx.fillStyle = "#eee";
|
|
120
|
+
ctx.textAlign = "center";
|
|
121
|
+
ctx.textBaseline = "middle";
|
|
122
|
+
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
ctx.fillText(`🕐 ${now}`, 300, 100);
|
|
125
|
+
|
|
126
|
+
// Return as PNG data URL
|
|
127
|
+
return await canvas.toDataURLAsync("image/webp");
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 5. Call it from your Worker like any normal function
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// src/index.ts
|
|
135
|
+
import { Hono } from "hono";
|
|
136
|
+
import { renderClock } from "./clock.container";
|
|
137
|
+
|
|
138
|
+
const app = new Hono();
|
|
139
|
+
|
|
140
|
+
app.get("/clock", async (c) => {
|
|
141
|
+
// 😱 Looks like a normal function call!
|
|
142
|
+
// But behind the scenes, RPC flies to the container!
|
|
143
|
+
const pngDataUrl = await renderClock();
|
|
144
|
+
|
|
145
|
+
// Convert data URL to response
|
|
146
|
+
return fetch(pngDataUrl);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Don't forget to export the DO
|
|
150
|
+
export { NodejsFnContainer } from "./__generated__/create-nodejs-fn.do";
|
|
151
|
+
export default { fetch: app.fetch };
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 6. Launch!
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pnpm dev
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Visit `http://localhost:5173/clock` to see a dynamically generated image with the current timestamp! 🎉
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
## 🪄 The Black Magic Revealed
|
|
164
|
+
|
|
165
|
+
### 1️⃣ Transparent Proxy Generation via AST Transformation
|
|
166
|
+
|
|
167
|
+
Uses `ts-morph` to **statically analyze** `*.container.ts` files.
|
|
168
|
+
Detects exported functions and **auto-generates proxy functions with identical type signatures**.
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// Your code (clock.container.ts)
|
|
172
|
+
export const renderClock = nodejsFn(async () => {
|
|
173
|
+
// Node.js native processing...
|
|
174
|
+
return pngDataUrl;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 🧙 The plugin auto-generates a proxy
|
|
178
|
+
// → Types fully preserved! IDE autocomplete works!
|
|
179
|
+
// → Calls are routed to the container via RPC!
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### 2️⃣ Container Management via Durable Objects
|
|
183
|
+
|
|
184
|
+
Uses Cloudflare **Durable Objects** to manage container connections.
|
|
185
|
+
Stateful, with multi-instance routing support!
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// Route to specific instances with containerKey
|
|
189
|
+
export const renderClock = nodejsFn(
|
|
190
|
+
async () => { /* ... */ },
|
|
191
|
+
containerKey(({ args }) => {
|
|
192
|
+
// Route to containers based on arguments! Load balancing!
|
|
193
|
+
return `instance-${Math.floor(Math.random() * 3)}`;
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 3️⃣ Fully Automated Build with esbuild + Docker
|
|
199
|
+
|
|
200
|
+
- Bundles container server code with **esbuild**
|
|
201
|
+
- **Auto-generates Dockerfile**
|
|
202
|
+
- Native deps specified in `external` are auto-extracted to `package.json`
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
## ⚙️ Plugin Options
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
createNodejsFnPlugin({
|
|
209
|
+
// File patterns for container functions (default: ["src/**/*.container.ts"])
|
|
210
|
+
files: ["src/**/*.container.ts"],
|
|
211
|
+
|
|
212
|
+
// Output directory for generated files (default: "src/__generated__")
|
|
213
|
+
generatedDir: "src/__generated__",
|
|
214
|
+
|
|
215
|
+
// Durable Object binding name (default: "NODEJS_FN")
|
|
216
|
+
binding: "NODEJS_FN",
|
|
217
|
+
|
|
218
|
+
// Container class name (default: "NodejsFnContainer")
|
|
219
|
+
className: "NodejsFnContainer",
|
|
220
|
+
|
|
221
|
+
// Container port (default: 8080)
|
|
222
|
+
containerPort: 8080,
|
|
223
|
+
|
|
224
|
+
// External dependencies to install in container
|
|
225
|
+
external: ["@napi-rs/canvas", "sharp"],
|
|
226
|
+
|
|
227
|
+
// Docker image settings
|
|
228
|
+
docker: {
|
|
229
|
+
baseImage: "node:20-bookworm-slim",
|
|
230
|
+
systemPackages: [
|
|
231
|
+
"fontconfig",
|
|
232
|
+
"fonts-noto-core",
|
|
233
|
+
"fonts-noto-cjk",
|
|
234
|
+
"fonts-noto-color-emoji",
|
|
235
|
+
],
|
|
236
|
+
preInstallCommands: [],
|
|
237
|
+
postInstallCommands: [],
|
|
238
|
+
env: { MY_VAR: "value" },
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
// Environment variables to pass from Worker to Container
|
|
242
|
+
workerEnvVars: ["API_KEY", "SECRET"],
|
|
243
|
+
|
|
244
|
+
// Auto-rebuild on file changes (default: true)
|
|
245
|
+
autoRebuildContainers: true,
|
|
246
|
+
|
|
247
|
+
// Rebuild debounce time (default: 600ms)
|
|
248
|
+
rebuildDebounceMs: 600,
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 🏗️ Internal Architecture
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
project/
|
|
258
|
+
├── src/
|
|
259
|
+
│ ├── clock.container.ts # Your code
|
|
260
|
+
│ ├── index.ts # Worker entry
|
|
261
|
+
│ └── __generated__/ # 🧙 Auto-generated magic
|
|
262
|
+
│ ├── create-nodejs-fn.ts # RPC client & type definitions
|
|
263
|
+
│ ├── create-nodejs-fn.do.ts # Durable Object class
|
|
264
|
+
│ ├── create-nodejs-fn.context.ts # Container key resolution
|
|
265
|
+
│ ├── create-nodejs-fn.runtime.ts # nodejsFn / containerKey helpers
|
|
266
|
+
│ └── proxy.src__clock.container.ts # Proxy functions
|
|
267
|
+
│
|
|
268
|
+
└── .create-nodejs-fn/ # 🐳 Container build artifacts
|
|
269
|
+
├── Dockerfile # Auto-generated
|
|
270
|
+
├── container.entry.ts # Server entry (generated)
|
|
271
|
+
├── server.mjs # Bundled with esbuild
|
|
272
|
+
└── package.json # Only external deps extracted
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## ⚠️ Limitations & Caveats
|
|
278
|
+
|
|
279
|
+
- **Not for production**: This is an experimental project
|
|
280
|
+
- Requires **Cloudflare Containers** (currently in beta)
|
|
281
|
+
- Function arguments and return values must be **serializable**
|
|
282
|
+
- Container cold starts exist (adjust with `sleepAfter`)
|
|
283
|
+
- Debugging is hard (check your logs if something breaks)
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 📝 License
|
|
288
|
+
|
|
289
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// src/fs-utils.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function ensureDir(p) {
|
|
5
|
+
fs.mkdirSync(p, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
function writeFileIfChanged(filePath, content) {
|
|
8
|
+
ensureDir(path.dirname(filePath));
|
|
9
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
|
10
|
+
if (current === content) return;
|
|
11
|
+
fs.writeFileSync(filePath, content);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
ensureDir,
|
|
16
|
+
writeFileIfChanged
|
|
17
|
+
};
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/cli.ts
|
|
27
|
+
var import_node_fs2 = __toESM(require("fs"));
|
|
28
|
+
var import_node_path2 = __toESM(require("path"));
|
|
29
|
+
var import_node_url = require("url");
|
|
30
|
+
var import_promises = __toESM(require("readline/promises"));
|
|
31
|
+
var import_gunshi = require("gunshi");
|
|
32
|
+
|
|
33
|
+
// src/fs-utils.ts
|
|
34
|
+
var import_node_fs = __toESM(require("fs"));
|
|
35
|
+
var import_node_path = __toESM(require("path"));
|
|
36
|
+
function ensureDir(p) {
|
|
37
|
+
import_node_fs.default.mkdirSync(p, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
function writeFileIfChanged(filePath, content) {
|
|
40
|
+
ensureDir(import_node_path.default.dirname(filePath));
|
|
41
|
+
const current = import_node_fs.default.existsSync(filePath) ? import_node_fs.default.readFileSync(filePath, "utf8") : null;
|
|
42
|
+
if (current === content) return;
|
|
43
|
+
import_node_fs.default.writeFileSync(filePath, content);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/cli.ts
|
|
47
|
+
var import_meta = {};
|
|
48
|
+
var moduleDir = typeof __dirname === "string" ? __dirname : import_node_path2.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
49
|
+
var pkgJsonPath = import_node_path2.default.resolve(moduleDir, "../package.json");
|
|
50
|
+
var pkg = import_node_fs2.default.existsSync(pkgJsonPath) ? JSON.parse(import_node_fs2.default.readFileSync(pkgJsonPath, "utf8")) : { version: "0.0.0" };
|
|
51
|
+
var VERSION = pkg.version ?? "0.0.0";
|
|
52
|
+
var initCommand = (0, import_gunshi.define)({
|
|
53
|
+
name: "init",
|
|
54
|
+
description: "Interactively scaffold create-nodejs-fn files",
|
|
55
|
+
args: {
|
|
56
|
+
yes: {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
short: "y",
|
|
59
|
+
description: "Use defaults and skip prompts"
|
|
60
|
+
},
|
|
61
|
+
force: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
short: "f",
|
|
64
|
+
description: "Overwrite existing files without asking"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
run: async (ctx) => {
|
|
68
|
+
const yes = Boolean(ctx.values.yes);
|
|
69
|
+
const force = Boolean(ctx.values.force);
|
|
70
|
+
await runInit({ yes, force });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
async function runInit(opts) {
|
|
74
|
+
const cwd = process.cwd();
|
|
75
|
+
const defaults = await gatherDefaults(cwd);
|
|
76
|
+
const prompter = createPrompter(opts.yes);
|
|
77
|
+
const answers = {
|
|
78
|
+
name: await prompter.text("Worker name", defaults.name),
|
|
79
|
+
main: normalizeEntry(await prompter.text("Entry file", defaults.main)),
|
|
80
|
+
className: await prompter.text("Container class name", defaults.className),
|
|
81
|
+
binding: await prompter.text("Durable Object binding", defaults.binding),
|
|
82
|
+
image: await prompter.text("Container image path", defaults.image),
|
|
83
|
+
compatibilityDate: await prompter.text("Compatibility date", defaults.compatibilityDate),
|
|
84
|
+
maxInstances: await prompter.number("Max container instances", defaults.maxInstances)
|
|
85
|
+
};
|
|
86
|
+
const results = [];
|
|
87
|
+
if (await ensureDockerfile(cwd, opts, prompter)) {
|
|
88
|
+
results.push(".create-nodejs-fn/Dockerfile");
|
|
89
|
+
}
|
|
90
|
+
if (updateGitignore(cwd)) {
|
|
91
|
+
results.push(".gitignore");
|
|
92
|
+
}
|
|
93
|
+
if (ensureGeneratedDir(cwd)) {
|
|
94
|
+
results.push("src/__generated__/");
|
|
95
|
+
}
|
|
96
|
+
const wranglerResult = await writeWranglerJsonc(cwd, answers, opts, prompter);
|
|
97
|
+
if (wranglerResult) {
|
|
98
|
+
results.push("wrangler.jsonc");
|
|
99
|
+
}
|
|
100
|
+
if (ensureEntryExportsDo(cwd, answers, opts)) {
|
|
101
|
+
results.push(answers.main);
|
|
102
|
+
}
|
|
103
|
+
prompter.close();
|
|
104
|
+
if (results.length === 0) {
|
|
105
|
+
console.log("All files already up to date. Nothing to do.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
console.log("Updated:");
|
|
109
|
+
for (const r of results) {
|
|
110
|
+
console.log(` - ${r}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function ensureDockerfile(cwd, opts, prompter) {
|
|
114
|
+
const dockerfilePath = import_node_path2.default.join(cwd, ".create-nodejs-fn", "Dockerfile");
|
|
115
|
+
const dockerDir = import_node_path2.default.dirname(dockerfilePath);
|
|
116
|
+
ensureDir(dockerDir);
|
|
117
|
+
if (import_node_fs2.default.existsSync(dockerfilePath) && !opts.force) {
|
|
118
|
+
const overwrite = await prompter.confirm(
|
|
119
|
+
"Dockerfile already exists. Overwrite it?",
|
|
120
|
+
false
|
|
121
|
+
);
|
|
122
|
+
if (!overwrite) return false;
|
|
123
|
+
}
|
|
124
|
+
const content = [
|
|
125
|
+
"# create-nodejs-fn container image",
|
|
126
|
+
"# Generated by `create-nodejs-fn init`. The build step will refresh this file.",
|
|
127
|
+
"FROM node:20-slim",
|
|
128
|
+
"WORKDIR /app",
|
|
129
|
+
"RUN corepack enable",
|
|
130
|
+
"# Dependencies are injected via the generated package.json during build.",
|
|
131
|
+
"COPY package.json ./",
|
|
132
|
+
"RUN pnpm install --prod --no-frozen-lockfile",
|
|
133
|
+
"# The server bundle is generated at build time.",
|
|
134
|
+
"COPY ./server.mjs ./server.mjs",
|
|
135
|
+
"ENV NODE_ENV=production",
|
|
136
|
+
"EXPOSE 8080",
|
|
137
|
+
'CMD ["node", "./server.mjs"]',
|
|
138
|
+
""
|
|
139
|
+
].join("\n");
|
|
140
|
+
writeFileIfChanged(dockerfilePath, content);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function updateGitignore(cwd) {
|
|
144
|
+
const target = import_node_path2.default.join(cwd, ".gitignore");
|
|
145
|
+
const existing = import_node_fs2.default.existsSync(target) ? import_node_fs2.default.readFileSync(target, "utf8").split(/\r?\n/) : [];
|
|
146
|
+
const block = [
|
|
147
|
+
"# create-nodejs-fn",
|
|
148
|
+
".create-nodejs-fn/*",
|
|
149
|
+
"!.create-nodejs-fn/Dockerfile",
|
|
150
|
+
"src/__generated__"
|
|
151
|
+
];
|
|
152
|
+
const lines = [...existing];
|
|
153
|
+
const present = new Set(lines);
|
|
154
|
+
const missing = block.filter((line) => !present.has(line));
|
|
155
|
+
const changed = missing.length > 0;
|
|
156
|
+
if (changed) {
|
|
157
|
+
if (lines.length && lines[lines.length - 1] !== "") {
|
|
158
|
+
lines.push("");
|
|
159
|
+
}
|
|
160
|
+
for (const line of block) {
|
|
161
|
+
if (!present.has(line)) {
|
|
162
|
+
lines.push(line);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (changed) {
|
|
167
|
+
const cleaned = trimBlankDuplicates(lines).join("\n");
|
|
168
|
+
import_node_fs2.default.writeFileSync(target, cleaned.endsWith("\n") ? cleaned : `${cleaned}
|
|
169
|
+
`);
|
|
170
|
+
}
|
|
171
|
+
return changed;
|
|
172
|
+
}
|
|
173
|
+
function ensureGeneratedDir(cwd) {
|
|
174
|
+
const dir = import_node_path2.default.join(cwd, "src", "__generated__");
|
|
175
|
+
if (import_node_fs2.default.existsSync(dir)) return false;
|
|
176
|
+
ensureDir(dir);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
async function writeWranglerJsonc(cwd, answers, opts, prompter) {
|
|
180
|
+
const target = import_node_path2.default.join(cwd, "wrangler.jsonc");
|
|
181
|
+
const exists = import_node_fs2.default.existsSync(target);
|
|
182
|
+
if (exists && !opts.force) {
|
|
183
|
+
const proceed = await prompter.confirm("wrangler.jsonc already exists. Merge updates?", true);
|
|
184
|
+
if (!proceed) return false;
|
|
185
|
+
}
|
|
186
|
+
const existing = exists ? readJsonc(target) : {};
|
|
187
|
+
const merged = mergeWranglerConfig(existing, answers);
|
|
188
|
+
writeFileIfChanged(target, `${JSON.stringify(merged, null, 2)}
|
|
189
|
+
`);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
function readJsonc(filePath) {
|
|
193
|
+
try {
|
|
194
|
+
const raw = import_node_fs2.default.readFileSync(filePath, "utf8");
|
|
195
|
+
const withoutBlock = raw.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
196
|
+
const withoutLine = withoutBlock.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
197
|
+
return JSON.parse(withoutLine);
|
|
198
|
+
} catch {
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function mergeWranglerConfig(base, answers) {
|
|
203
|
+
const out = { ...base };
|
|
204
|
+
out.$schema = out.$schema ?? "node_modules/wrangler/config-schema.json";
|
|
205
|
+
out.name = out.name ?? answers.name;
|
|
206
|
+
out.main = out.main ?? (answers.main.startsWith("./") ? answers.main : `./${answers.main}`);
|
|
207
|
+
out.compatibility_date = out.compatibility_date ?? answers.compatibilityDate;
|
|
208
|
+
const flags = Array.isArray(out.compatibility_flags) ? out.compatibility_flags : [];
|
|
209
|
+
if (!flags.includes("nodejs_compat")) flags.push("nodejs_compat");
|
|
210
|
+
out.compatibility_flags = flags;
|
|
211
|
+
const containers = Array.isArray(out.containers) ? [...out.containers] : [];
|
|
212
|
+
const containerIdx = containers.findIndex((c) => c?.class_name === answers.className);
|
|
213
|
+
const containerEntry = {
|
|
214
|
+
class_name: answers.className,
|
|
215
|
+
image: answers.image,
|
|
216
|
+
max_instances: answers.maxInstances
|
|
217
|
+
};
|
|
218
|
+
if (containerIdx >= 0) {
|
|
219
|
+
containers[containerIdx] = { ...containers[containerIdx], ...containerEntry };
|
|
220
|
+
} else {
|
|
221
|
+
containers.push(containerEntry);
|
|
222
|
+
}
|
|
223
|
+
out.containers = containers;
|
|
224
|
+
const durable = typeof out.durable_objects === "object" && out.durable_objects !== null ? { ...out.durable_objects } : {};
|
|
225
|
+
const bindings = Array.isArray(durable.bindings) ? [...durable.bindings] : [];
|
|
226
|
+
const bindingIdx = bindings.findIndex((b) => b?.name === answers.binding);
|
|
227
|
+
const bindingEntry = { name: answers.binding, class_name: answers.className };
|
|
228
|
+
if (bindingIdx >= 0) {
|
|
229
|
+
bindings[bindingIdx] = { ...bindings[bindingIdx], ...bindingEntry };
|
|
230
|
+
} else {
|
|
231
|
+
bindings.push(bindingEntry);
|
|
232
|
+
}
|
|
233
|
+
durable.bindings = bindings;
|
|
234
|
+
out.durable_objects = durable;
|
|
235
|
+
const migrations = Array.isArray(out.migrations) ? [...out.migrations] : [];
|
|
236
|
+
const migIdx = migrations.findIndex((m) => m?.tag === "v1");
|
|
237
|
+
const ensureNewSqlite = (entry) => {
|
|
238
|
+
const classes = Array.isArray(entry.new_sqlite_classes) ? [...entry.new_sqlite_classes] : [];
|
|
239
|
+
if (!classes.includes(answers.className)) classes.push(answers.className);
|
|
240
|
+
entry.new_sqlite_classes = classes;
|
|
241
|
+
return entry;
|
|
242
|
+
};
|
|
243
|
+
if (migIdx >= 0) {
|
|
244
|
+
migrations[migIdx] = ensureNewSqlite({ ...migrations[migIdx], tag: migrations[migIdx].tag ?? "v1" });
|
|
245
|
+
} else {
|
|
246
|
+
migrations.push(ensureNewSqlite({ tag: "v1" }));
|
|
247
|
+
}
|
|
248
|
+
out.migrations = migrations;
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
function ensureEntryExportsDo(cwd, answers, opts) {
|
|
252
|
+
const entryRel = answers.main.startsWith("./") ? answers.main.slice(2) : answers.main;
|
|
253
|
+
const entryAbs = import_node_path2.default.join(cwd, entryRel);
|
|
254
|
+
ensureDir(import_node_path2.default.dirname(entryAbs));
|
|
255
|
+
const doAbs = import_node_path2.default.join(cwd, "src", "__generated__", "create-nodejs-fn.do.ts");
|
|
256
|
+
const doRel = import_node_path2.default.relative(import_node_path2.default.dirname(entryAbs), doAbs).replace(/\\/g, "/").replace(/\.ts$/, "");
|
|
257
|
+
const exportLine = `export { ${answers.className} } from "${doRel.startsWith(".") ? doRel : `./${doRel}`}";`;
|
|
258
|
+
if (import_node_fs2.default.existsSync(entryAbs)) {
|
|
259
|
+
const content = import_node_fs2.default.readFileSync(entryAbs, "utf8");
|
|
260
|
+
if (content.includes(exportLine) || content.match(new RegExp(`export\\s+\\{\\s*${answers.className}\\s*\\}.*create-nodejs-fn\\.do`))) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
const next = content.endsWith("\n") ? `${content}${exportLine}
|
|
264
|
+
` : `${content}
|
|
265
|
+
${exportLine}
|
|
266
|
+
`;
|
|
267
|
+
writeFileIfChanged(entryAbs, next);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
const template = `${exportLine}
|
|
271
|
+
`;
|
|
272
|
+
writeFileIfChanged(entryAbs, template);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
async function gatherDefaults(cwd) {
|
|
276
|
+
const pkgPath = import_node_path2.default.join(cwd, "package.json");
|
|
277
|
+
const pkgJson = import_node_fs2.default.existsSync(pkgPath) ? JSON.parse(import_node_fs2.default.readFileSync(pkgPath, "utf8")) : {};
|
|
278
|
+
return {
|
|
279
|
+
name: pkgJson.name ?? import_node_path2.default.basename(cwd),
|
|
280
|
+
main: "src/index.ts",
|
|
281
|
+
className: "NodejsFnContainer",
|
|
282
|
+
binding: "NODEJS_FN",
|
|
283
|
+
image: "./.create-nodejs-fn/Dockerfile",
|
|
284
|
+
compatibilityDate: defaultCompatibilityDate(),
|
|
285
|
+
maxInstances: 10
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function defaultCompatibilityDate() {
|
|
289
|
+
const d = /* @__PURE__ */ new Date();
|
|
290
|
+
const yyyy = d.getUTCFullYear();
|
|
291
|
+
const mm = `${d.getUTCMonth() + 1}`.padStart(2, "0");
|
|
292
|
+
const dd = `${d.getUTCDate()}`.padStart(2, "0");
|
|
293
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
294
|
+
}
|
|
295
|
+
function normalizeEntry(input) {
|
|
296
|
+
if (!input) return "src/index.ts";
|
|
297
|
+
const withSlash = input.startsWith("./") ? input : `./${input}`;
|
|
298
|
+
return withSlash.replace(/\\/g, "/");
|
|
299
|
+
}
|
|
300
|
+
function createPrompter(skip) {
|
|
301
|
+
if (skip || !process.stdin.isTTY) {
|
|
302
|
+
return {
|
|
303
|
+
text: async (_message, defaultValue) => defaultValue,
|
|
304
|
+
number: async (_message, defaultValue) => defaultValue,
|
|
305
|
+
confirm: async (_message, defaultValue = false) => defaultValue,
|
|
306
|
+
close: () => {
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const rl = import_promises.default.createInterface({
|
|
311
|
+
input: process.stdin,
|
|
312
|
+
output: process.stdout
|
|
313
|
+
});
|
|
314
|
+
return {
|
|
315
|
+
async text(message, defaultValue) {
|
|
316
|
+
const answer = await rl.question(formatPrompt(message, defaultValue));
|
|
317
|
+
return answer.trim() || defaultValue;
|
|
318
|
+
},
|
|
319
|
+
async number(message, defaultValue) {
|
|
320
|
+
const answer = await rl.question(formatPrompt(message, String(defaultValue)));
|
|
321
|
+
const parsed = Number.parseInt(answer.trim(), 10);
|
|
322
|
+
return Number.isFinite(parsed) ? parsed : defaultValue;
|
|
323
|
+
},
|
|
324
|
+
async confirm(message, defaultValue = false) {
|
|
325
|
+
const suffix = defaultValue ? " [Y/n] " : " [y/N] ";
|
|
326
|
+
const answer = (await rl.question(`${message}${suffix}`)).trim().toLowerCase();
|
|
327
|
+
if (!answer) return defaultValue;
|
|
328
|
+
return answer.startsWith("y");
|
|
329
|
+
},
|
|
330
|
+
close() {
|
|
331
|
+
rl.close();
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function formatPrompt(message, defaultValue) {
|
|
336
|
+
const suffix = defaultValue !== void 0 && defaultValue !== null ? ` (${defaultValue})` : "";
|
|
337
|
+
return `${message}${suffix}: `;
|
|
338
|
+
}
|
|
339
|
+
function trimBlankDuplicates(lines) {
|
|
340
|
+
const out = [];
|
|
341
|
+
for (const line of lines) {
|
|
342
|
+
if (line === "" && out.length > 0 && out[out.length - 1] === "") continue;
|
|
343
|
+
out.push(line);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
async function main() {
|
|
348
|
+
await (0, import_gunshi.cli)(process.argv.slice(2), initCommand, {
|
|
349
|
+
name: "create-nodejs-fn",
|
|
350
|
+
version: VERSION
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
main().catch((err) => {
|
|
354
|
+
console.error(err);
|
|
355
|
+
process.exitCode = 1;
|
|
356
|
+
});
|