ruvyxa 1.0.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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/bin/ruvyxa.js +82 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -0
- package/package.json +76 -0
- package/runtime/action-renderer.mjs +130 -0
- package/runtime/api-renderer.mjs +106 -0
- package/runtime/client-renderer.mjs +245 -0
- package/runtime/ssr-renderer.mjs +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thirawat Sinlapasomsak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# ruvyxa
|
|
2
|
+
|
|
3
|
+
Rust-powered full-stack TypeScript framework CLI and runtime.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install ruvyxa
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The `ruvyxa` npm package includes the TypeScript runtime files and a native CLI binary for the current release platform. Users do not need Rust or Cargo to run the CLI after installing from npm.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx ruvyxa dev --root .
|
|
17
|
+
npx ruvyxa build --root .
|
|
18
|
+
npx ruvyxa start --root .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Framework APIs are re-exported from `@ruvyxa/core`:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { defineConfig, action, loader } from "ruvyxa"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Publish Notes
|
|
28
|
+
|
|
29
|
+
This package runs `prepack` before publishing. The script builds `dist/` and copies the release binary into `native-bin/` so the published CLI can run outside the monorepo. Platform-specific packages such as `@ruvyxa/cli-win32-x64` can also provide the binary as optional dependencies.
|
package/bin/ruvyxa.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { chmodSync, existsSync } from "node:fs"
|
|
4
|
+
import { dirname, join, resolve } from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const packageRoot = resolve(here, "..")
|
|
9
|
+
const monorepoRoot = resolve(here, "../../..")
|
|
10
|
+
const executable = process.platform === "win32" ? "ruvyxa.exe" : "ruvyxa"
|
|
11
|
+
const platformKey = `${process.platform}-${process.arch}`
|
|
12
|
+
|
|
13
|
+
const binary = findBinary()
|
|
14
|
+
|
|
15
|
+
if (!binary) {
|
|
16
|
+
console.error(`Ruvyxa native CLI binary was not found for ${platformKey}.`)
|
|
17
|
+
console.error("Reinstall ruvyxa, or install the matching @ruvyxa/cli-* optional package.")
|
|
18
|
+
console.error("When working from source, run `cargo build -p ruvyxa_cli` first.")
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result = spawnSync(binary, process.argv.slice(2), {
|
|
23
|
+
cwd: process.cwd(),
|
|
24
|
+
stdio: "inherit",
|
|
25
|
+
shell: process.platform === "win32",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
if (result.error) {
|
|
29
|
+
console.error(`Failed to run Ruvyxa native CLI at ${binary}: ${result.error.message}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
process.exit(result.status ?? 1)
|
|
34
|
+
|
|
35
|
+
function findBinary() {
|
|
36
|
+
const bundled = resolve(packageRoot, "native-bin", platformKey, executable)
|
|
37
|
+
if (existsSync(bundled)) return prepareExecutable(bundled)
|
|
38
|
+
|
|
39
|
+
const optionalPackage = optionalBinaryPackageName()
|
|
40
|
+
if (optionalPackage) {
|
|
41
|
+
try {
|
|
42
|
+
const packageJson = import.meta.resolve(`${optionalPackage}/package.json`)
|
|
43
|
+
const packageRoot = dirname(fileURLToPath(packageJson))
|
|
44
|
+
const optionalBinary = join(packageRoot, "bin", executable)
|
|
45
|
+
if (existsSync(optionalBinary)) return prepareExecutable(optionalBinary)
|
|
46
|
+
} catch {
|
|
47
|
+
// Optional platform package is absent on unsupported platforms.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const profile of ["debug", "release"]) {
|
|
52
|
+
const sourceBinary = resolve(monorepoRoot, "target", profile, executable)
|
|
53
|
+
if (existsSync(sourceBinary)) return prepareExecutable(sourceBinary)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prepareExecutable(binary) {
|
|
60
|
+
if (process.platform !== "win32") {
|
|
61
|
+
chmodSync(binary, 0o755)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return binary
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function optionalBinaryPackageName() {
|
|
68
|
+
switch (platformKey) {
|
|
69
|
+
case "darwin-arm64":
|
|
70
|
+
return "@ruvyxa/cli-darwin-arm64"
|
|
71
|
+
case "darwin-x64":
|
|
72
|
+
return "@ruvyxa/cli-darwin-x64"
|
|
73
|
+
case "linux-arm64":
|
|
74
|
+
return "@ruvyxa/cli-linux-arm64"
|
|
75
|
+
case "linux-x64":
|
|
76
|
+
return "@ruvyxa/cli-linux-x64"
|
|
77
|
+
case "win32-x64":
|
|
78
|
+
return "@ruvyxa/cli-win32-x64"
|
|
79
|
+
default:
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA"}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ruvyxa",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The Ruvyxa CLI and runtime: Rust-powered full-stack TypeScript with SSR, actions, HMR, and production-accurate builds.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"ruvyxa": "./bin/ruvyxa.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"dist",
|
|
15
|
+
"native-bin",
|
|
16
|
+
"runtime/action-renderer.mjs",
|
|
17
|
+
"runtime/api-renderer.mjs",
|
|
18
|
+
"runtime/client-renderer.mjs",
|
|
19
|
+
"runtime/ssr-renderer.mjs",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"framework",
|
|
24
|
+
"typescript",
|
|
25
|
+
"react",
|
|
26
|
+
"rust",
|
|
27
|
+
"ssr",
|
|
28
|
+
"fullstack"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/thirawat27/ruvyxa.git",
|
|
33
|
+
"directory": "packages/ruvyxa"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/thirawat27/ruvyxa/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/thirawat27/ruvyxa#readme",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20.0.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./dist/index.d.ts",
|
|
48
|
+
"default": "./dist/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./server": {
|
|
51
|
+
"types": "./dist/server.d.ts",
|
|
52
|
+
"default": "./dist/server.js"
|
|
53
|
+
},
|
|
54
|
+
"./config": {
|
|
55
|
+
"types": "./dist/config.d.ts",
|
|
56
|
+
"default": "./dist/config.js"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"esbuild": "^0.27.2",
|
|
61
|
+
"@ruvyxa/core": "^1.0.0"
|
|
62
|
+
},
|
|
63
|
+
"optionalDependencies": {
|
|
64
|
+
"@ruvyxa/cli-darwin-arm64": "^1.0.0",
|
|
65
|
+
"@ruvyxa/cli-darwin-x64": "^1.0.0",
|
|
66
|
+
"@ruvyxa/cli-linux-arm64": "^1.0.0",
|
|
67
|
+
"@ruvyxa/cli-linux-x64": "^1.0.0",
|
|
68
|
+
"@ruvyxa/cli-win32-x64": "^1.0.0"
|
|
69
|
+
},
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "tsc -p tsconfig.json",
|
|
72
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
73
|
+
"test": "vitest run --passWithNoTests",
|
|
74
|
+
"format": "tsc -p tsconfig.json --noEmit"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { pathToFileURL } from "node:url"
|
|
6
|
+
|
|
7
|
+
import { build } from "esbuild"
|
|
8
|
+
|
|
9
|
+
const [
|
|
10
|
+
projectRootArg,
|
|
11
|
+
actionFileArg,
|
|
12
|
+
actionName = "",
|
|
13
|
+
payloadJson = "{}",
|
|
14
|
+
requestPath = "/",
|
|
15
|
+
] = process.argv.slice(2)
|
|
16
|
+
|
|
17
|
+
if (!projectRootArg || !actionFileArg || !actionName) {
|
|
18
|
+
fail("RUV1503", "Action renderer requires projectRoot, actionFile, and actionName arguments.")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const projectRoot = path.resolve(projectRootArg)
|
|
22
|
+
const actionFile = path.resolve(actionFileArg)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const bundleFile = await bundleActionModule(projectRoot, actionFile)
|
|
26
|
+
const mod = await import(pathToFileURL(bundleFile).href + `?t=${Date.now()}`)
|
|
27
|
+
const action = mod[actionName]
|
|
28
|
+
|
|
29
|
+
if (typeof action !== "function" || action.ruvyxa?.kind !== "action") {
|
|
30
|
+
process.stdout.write(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
ok: true,
|
|
33
|
+
status: 404,
|
|
34
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
error: `Action ${actionName} was not found in ${path.basename(actionFile)}`,
|
|
37
|
+
}),
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
process.exit(0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const input = parsePayload(payloadJson)
|
|
44
|
+
const invalidated = []
|
|
45
|
+
const request = new Request(`http://localhost${requestPath}`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
body: JSON.stringify(input),
|
|
49
|
+
})
|
|
50
|
+
const result = await action(input, {
|
|
51
|
+
request,
|
|
52
|
+
invalidate(key) {
|
|
53
|
+
invalidated.push(key)
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
const response = normalizeActionResult(result, invalidated)
|
|
57
|
+
const body = await response.text()
|
|
58
|
+
const headers = Object.fromEntries(response.headers.entries())
|
|
59
|
+
|
|
60
|
+
process.stdout.write(
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
ok: true,
|
|
63
|
+
status: response.status,
|
|
64
|
+
headers,
|
|
65
|
+
body,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
} catch (error) {
|
|
69
|
+
fail("RUV1500", error instanceof Error ? error.message : String(error), error?.stack)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function bundleActionModule(projectRoot, actionFile) {
|
|
73
|
+
const cacheDir = path.join(projectRoot, ".ruvyxa", "cache", "actions")
|
|
74
|
+
await mkdir(cacheDir, { recursive: true })
|
|
75
|
+
|
|
76
|
+
const moduleCode = `export * from ${JSON.stringify(toImportPath(actionFile))}`
|
|
77
|
+
const hash = createHash("sha256")
|
|
78
|
+
.update(moduleCode)
|
|
79
|
+
.update(actionFile)
|
|
80
|
+
.digest("hex")
|
|
81
|
+
.slice(0, 16)
|
|
82
|
+
const outfile = path.join(cacheDir, `${hash}.mjs`)
|
|
83
|
+
|
|
84
|
+
await build({
|
|
85
|
+
stdin: {
|
|
86
|
+
contents: moduleCode,
|
|
87
|
+
resolveDir: projectRoot,
|
|
88
|
+
sourcefile: "ruvyxa:action-entry.ts",
|
|
89
|
+
loader: "ts",
|
|
90
|
+
},
|
|
91
|
+
outfile,
|
|
92
|
+
bundle: true,
|
|
93
|
+
format: "esm",
|
|
94
|
+
platform: "node",
|
|
95
|
+
absWorkingDir: projectRoot,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return outfile
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parsePayload(payloadJson) {
|
|
102
|
+
let parsed
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(payloadJson || "{}")
|
|
105
|
+
} catch {
|
|
106
|
+
parsed = Object.fromEntries(new URLSearchParams(payloadJson))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (parsed && typeof parsed === "object" && "input" in parsed) {
|
|
110
|
+
return parsed.input
|
|
111
|
+
}
|
|
112
|
+
return parsed
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeActionResult(result, invalidated) {
|
|
116
|
+
if (result instanceof Response) {
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Response.json({ data: result, invalidated })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toImportPath(file) {
|
|
124
|
+
return path.resolve(file).replaceAll("\\", "/")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fail(code, message, stack) {
|
|
128
|
+
process.stdout.write(JSON.stringify({ ok: false, code, message, stack }))
|
|
129
|
+
process.exit(1)
|
|
130
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { pathToFileURL } from "node:url"
|
|
6
|
+
|
|
7
|
+
import { build } from "esbuild"
|
|
8
|
+
|
|
9
|
+
const [
|
|
10
|
+
projectRootArg,
|
|
11
|
+
routeFileArg,
|
|
12
|
+
method = "GET",
|
|
13
|
+
requestPath = "/",
|
|
14
|
+
paramsJson = "{}",
|
|
15
|
+
] = process.argv.slice(2)
|
|
16
|
+
|
|
17
|
+
if (!projectRootArg || !routeFileArg) {
|
|
18
|
+
fail("RUV1201", "API renderer requires projectRoot and routeFile arguments.")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const projectRoot = path.resolve(projectRootArg)
|
|
22
|
+
const routeFile = path.resolve(routeFileArg)
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const bundleFile = await bundleApiModule(projectRoot, routeFile)
|
|
26
|
+
const mod = await import(pathToFileURL(bundleFile).href + `?t=${Date.now()}`)
|
|
27
|
+
const handler = mod[method.toUpperCase()]
|
|
28
|
+
|
|
29
|
+
if (typeof handler !== "function") {
|
|
30
|
+
process.stdout.write(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
ok: true,
|
|
33
|
+
status: 405,
|
|
34
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
35
|
+
body: `Method ${method.toUpperCase()} is not allowed`,
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
process.exit(0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const request = new Request(`http://localhost${requestPath}`, { method: method.toUpperCase() })
|
|
42
|
+
const result = await handler({
|
|
43
|
+
request,
|
|
44
|
+
params: JSON.parse(paramsJson),
|
|
45
|
+
})
|
|
46
|
+
const response = normalizeResponse(result)
|
|
47
|
+
const body = await response.text()
|
|
48
|
+
const headers = Object.fromEntries(response.headers.entries())
|
|
49
|
+
|
|
50
|
+
process.stdout.write(
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
ok: true,
|
|
53
|
+
status: response.status,
|
|
54
|
+
headers,
|
|
55
|
+
body,
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
fail("RUV1200", error instanceof Error ? error.message : String(error), error?.stack)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function bundleApiModule(projectRoot, routeFile) {
|
|
63
|
+
const cacheDir = path.join(projectRoot, ".ruvyxa", "cache", "api")
|
|
64
|
+
await mkdir(cacheDir, { recursive: true })
|
|
65
|
+
|
|
66
|
+
const moduleCode = `export * from ${JSON.stringify(toImportPath(routeFile))}`
|
|
67
|
+
const hash = createHash("sha256")
|
|
68
|
+
.update(moduleCode)
|
|
69
|
+
.update(routeFile)
|
|
70
|
+
.digest("hex")
|
|
71
|
+
.slice(0, 16)
|
|
72
|
+
const outfile = path.join(cacheDir, `${hash}.mjs`)
|
|
73
|
+
|
|
74
|
+
await build({
|
|
75
|
+
stdin: {
|
|
76
|
+
contents: moduleCode,
|
|
77
|
+
resolveDir: projectRoot,
|
|
78
|
+
sourcefile: "ruvyxa:api-entry.ts",
|
|
79
|
+
loader: "ts",
|
|
80
|
+
},
|
|
81
|
+
outfile,
|
|
82
|
+
bundle: true,
|
|
83
|
+
format: "esm",
|
|
84
|
+
platform: "node",
|
|
85
|
+
absWorkingDir: projectRoot,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return outfile
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeResponse(result) {
|
|
92
|
+
if (result instanceof Response) {
|
|
93
|
+
return result
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Response.json(result)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toImportPath(file) {
|
|
100
|
+
return path.resolve(file).replaceAll("\\", "/")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fail(code, message, stack) {
|
|
104
|
+
process.stdout.write(JSON.stringify({ ok: false, code, message, stack }))
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { mkdir, readFile } from "node:fs/promises"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
|
|
7
|
+
import { build } from "esbuild"
|
|
8
|
+
|
|
9
|
+
const [projectRootArg, appDirArg, pageFileArg, requestPath = "/", paramsJson = "{}"] =
|
|
10
|
+
process.argv.slice(2)
|
|
11
|
+
|
|
12
|
+
if (!projectRootArg || !appDirArg || !pageFileArg) {
|
|
13
|
+
fail("RUV1301", "Client renderer requires projectRoot, appDir, and pageFile arguments.")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const projectRoot = path.resolve(projectRootArg)
|
|
17
|
+
const appDir = path.resolve(appDirArg)
|
|
18
|
+
const pageFile = path.resolve(pageFileArg)
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const layouts = collectLayouts(appDir, path.dirname(pageFile))
|
|
22
|
+
const bundleFile = await bundleClientModule(projectRoot, pageFile, layouts, requestPath, paramsJson)
|
|
23
|
+
const script = await readFile(bundleFile, "utf8")
|
|
24
|
+
process.stdout.write(JSON.stringify({ ok: true, script }))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
fail("RUV1300", error instanceof Error ? error.message : String(error), error?.stack)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectLayouts(appDir, routeDir) {
|
|
30
|
+
const layouts = []
|
|
31
|
+
let current = appDir
|
|
32
|
+
|
|
33
|
+
pushIfExists(layouts, path.join(current, "layout.tsx"))
|
|
34
|
+
|
|
35
|
+
const relative = path.relative(appDir, routeDir)
|
|
36
|
+
if (relative && !relative.startsWith("..")) {
|
|
37
|
+
for (const segment of relative.split(path.sep)) {
|
|
38
|
+
if (!segment) continue
|
|
39
|
+
current = path.join(current, segment)
|
|
40
|
+
pushIfExists(layouts, path.join(current, "layout.tsx"))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return layouts
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pushIfExists(collection, file) {
|
|
48
|
+
if (existsSync(file)) {
|
|
49
|
+
collection.push(file)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function bundleClientModule(projectRoot, pageFile, layouts, requestPath, paramsJson) {
|
|
54
|
+
const cacheDir = path.join(projectRoot, ".ruvyxa", "cache", "client")
|
|
55
|
+
await mkdir(cacheDir, { recursive: true })
|
|
56
|
+
|
|
57
|
+
const imports = [`import Page from ${JSON.stringify(toImportPath(pageFile))}`]
|
|
58
|
+
const wrappers = []
|
|
59
|
+
|
|
60
|
+
layouts.forEach((layoutFile, index) => {
|
|
61
|
+
imports.push(`import Layout${index} from ${JSON.stringify(toImportPath(layoutFile))}`)
|
|
62
|
+
wrappers.push(`Layout${index}`)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const moduleCode = `
|
|
66
|
+
import React from "react"
|
|
67
|
+
import { hydrateRoot } from "react-dom/client"
|
|
68
|
+
${imports.join("\n")}
|
|
69
|
+
|
|
70
|
+
const params = globalThis.__RUVYXA_ROUTE_PARAMS__ ?? ${paramsJson}
|
|
71
|
+
const currentRequestPath = globalThis.__RUVYXA_REQUEST_PATH__ ?? ${JSON.stringify(requestPath)}
|
|
72
|
+
let tree = React.createElement(Page, { params, requestPath: currentRequestPath })
|
|
73
|
+
for (const Layout of [${wrappers.join(", ")}].reverse()) {
|
|
74
|
+
tree = React.createElement(Layout, null, tree)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (globalThis.__RUVYXA_ROOT__) {
|
|
78
|
+
globalThis.__RUVYXA_ROOT__.render(tree)
|
|
79
|
+
} else {
|
|
80
|
+
globalThis.__RUVYXA_ROOT__ = hydrateRoot(document, tree)
|
|
81
|
+
}
|
|
82
|
+
window.__RUVYXA_HYDRATED = true
|
|
83
|
+
`
|
|
84
|
+
|
|
85
|
+
const hash = createHash("sha256")
|
|
86
|
+
.update(moduleCode)
|
|
87
|
+
.update(pageFile)
|
|
88
|
+
.digest("hex")
|
|
89
|
+
.slice(0, 16)
|
|
90
|
+
const outfile = path.join(cacheDir, `${hash}.js`)
|
|
91
|
+
|
|
92
|
+
await build({
|
|
93
|
+
stdin: {
|
|
94
|
+
contents: moduleCode,
|
|
95
|
+
resolveDir: projectRoot,
|
|
96
|
+
sourcefile: "ruvyxa:client-entry.tsx",
|
|
97
|
+
loader: "tsx",
|
|
98
|
+
},
|
|
99
|
+
outfile,
|
|
100
|
+
bundle: true,
|
|
101
|
+
format: "iife",
|
|
102
|
+
platform: "browser",
|
|
103
|
+
jsx: "automatic",
|
|
104
|
+
minify: process.env.RUVYXA_CLIENT_MINIFY === "1",
|
|
105
|
+
treeShaking: true,
|
|
106
|
+
absWorkingDir: projectRoot,
|
|
107
|
+
plugins: [
|
|
108
|
+
clientBoundaryPlugin(projectRoot),
|
|
109
|
+
{
|
|
110
|
+
name: "ruvyxa-css-empty-module",
|
|
111
|
+
setup(build) {
|
|
112
|
+
build.onLoad({ filter: /\.css$/ }, () => ({ contents: "", loader: "js" }))
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return outfile
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clientBoundaryPlugin(projectRoot) {
|
|
122
|
+
return {
|
|
123
|
+
name: "ruvyxa-client-boundary",
|
|
124
|
+
setup(build) {
|
|
125
|
+
build.onResolve({ filter: /^server-only$/ }, (args) => {
|
|
126
|
+
throw boundaryError(
|
|
127
|
+
"RUV1007",
|
|
128
|
+
"Server-only module imported into client bundle",
|
|
129
|
+
args.importer,
|
|
130
|
+
'Remove `import "server-only"` from code that is reachable by the browser bundle.',
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
build.onResolve({ filter: /^client-only$/ }, () => ({
|
|
135
|
+
path: "ruvyxa:client-only",
|
|
136
|
+
namespace: "ruvyxa-virtual",
|
|
137
|
+
}))
|
|
138
|
+
|
|
139
|
+
build.onLoad({ filter: /^ruvyxa:client-only$/, namespace: "ruvyxa-virtual" }, () => ({
|
|
140
|
+
contents: "",
|
|
141
|
+
loader: "js",
|
|
142
|
+
}))
|
|
143
|
+
|
|
144
|
+
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
|
|
145
|
+
if (!isProjectSource(projectRoot, args.path)) {
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const normalized = args.path.replaceAll("\\", "/")
|
|
150
|
+
const contents = await readFile(args.path, "utf8")
|
|
151
|
+
|
|
152
|
+
if (isServerOnlyPath(projectRoot, args.path)) {
|
|
153
|
+
throw boundaryError(
|
|
154
|
+
"RUV1007",
|
|
155
|
+
"Server-only file imported into client bundle",
|
|
156
|
+
args.path,
|
|
157
|
+
"Move this import behind a server loader/API route, or pass serialized data into the page.",
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (contents.includes('import "server-only"') || contents.includes("import 'server-only'")) {
|
|
162
|
+
throw boundaryError(
|
|
163
|
+
"RUV1007",
|
|
164
|
+
"Server-only module imported into client bundle",
|
|
165
|
+
args.path,
|
|
166
|
+
'Move server-only code out of the browser graph or remove `import "server-only"`.',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const privateEnv = findPrivateEnvAccess(contents)
|
|
171
|
+
if (privateEnv) {
|
|
172
|
+
throw boundaryError(
|
|
173
|
+
"RUV1008",
|
|
174
|
+
"Private environment variable used in client bundle",
|
|
175
|
+
args.path,
|
|
176
|
+
`Rename ${privateEnv} to RUVYXA_PUBLIC_* if it is safe for browsers, or move the code to server-only logic.`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
contents,
|
|
182
|
+
loader: loaderFor(normalized),
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isProjectSource(projectRoot, file) {
|
|
190
|
+
const relative = path.relative(projectRoot, file)
|
|
191
|
+
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) && !relative.includes("node_modules")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isServerOnlyPath(projectRoot, file) {
|
|
195
|
+
const normalized = path.relative(projectRoot, file).replaceAll("\\", "/")
|
|
196
|
+
return (
|
|
197
|
+
normalized === "server.ts" ||
|
|
198
|
+
normalized === "server.js" ||
|
|
199
|
+
normalized.startsWith("server/") ||
|
|
200
|
+
normalized.endsWith("/server.ts") ||
|
|
201
|
+
normalized.endsWith("/server.js")
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function findPrivateEnvAccess(contents) {
|
|
206
|
+
const matches = contents.matchAll(/\bprocess\.env\.([A-Z_][A-Z0-9_]*)/g)
|
|
207
|
+
|
|
208
|
+
for (const match of matches) {
|
|
209
|
+
const name = match[1]
|
|
210
|
+
if (name !== "NODE_ENV" && !name.startsWith("RUVYXA_PUBLIC_")) {
|
|
211
|
+
return name
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function loaderFor(file) {
|
|
219
|
+
if (file.endsWith(".tsx")) return "tsx"
|
|
220
|
+
if (file.endsWith(".jsx")) return "jsx"
|
|
221
|
+
if (file.endsWith(".ts") || file.endsWith(".mts") || file.endsWith(".cts")) return "ts"
|
|
222
|
+
return "js"
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function boundaryError(code, title, file, fix) {
|
|
226
|
+
return new Error(`${code}: ${title}
|
|
227
|
+
|
|
228
|
+
File:
|
|
229
|
+
${file || "(entrypoint)"}
|
|
230
|
+
|
|
231
|
+
Why this is a problem:
|
|
232
|
+
This module is reachable from the browser hydration bundle.
|
|
233
|
+
|
|
234
|
+
Fix:
|
|
235
|
+
${fix}`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function toImportPath(file) {
|
|
239
|
+
return path.resolve(file).replaceAll("\\", "/")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fail(code, message, stack) {
|
|
243
|
+
process.stdout.write(JSON.stringify({ ok: false, code, message, stack }))
|
|
244
|
+
process.exit(1)
|
|
245
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { mkdir } from "node:fs/promises"
|
|
5
|
+
import { createRequire } from "node:module"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { pathToFileURL } from "node:url"
|
|
8
|
+
|
|
9
|
+
import { build } from "esbuild"
|
|
10
|
+
|
|
11
|
+
const [projectRootArg, appDirArg, pageFileArg, requestPath = "/", paramsJson = "{}"] =
|
|
12
|
+
process.argv.slice(2)
|
|
13
|
+
|
|
14
|
+
if (!projectRootArg || !appDirArg || !pageFileArg) {
|
|
15
|
+
fail("RUV1101", "SSR renderer requires projectRoot, appDir, and pageFile arguments.")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const projectRoot = path.resolve(projectRootArg)
|
|
19
|
+
const appDir = path.resolve(appDirArg)
|
|
20
|
+
const pageFile = path.resolve(pageFileArg)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const requireFromProject = createRequire(path.join(projectRoot, "package.json"))
|
|
24
|
+
requireFromProject.resolve("react")
|
|
25
|
+
requireFromProject.resolve("react-dom/server")
|
|
26
|
+
|
|
27
|
+
const layouts = collectLayouts(appDir, path.dirname(pageFile))
|
|
28
|
+
const bundleFile = await bundleSsrModule(projectRoot, pageFile, layouts)
|
|
29
|
+
const mod = await import(pathToFileURL(bundleFile).href + `?t=${Date.now()}`)
|
|
30
|
+
const html = await mod.render({ path: requestPath, params: JSON.parse(paramsJson) })
|
|
31
|
+
|
|
32
|
+
process.stdout.write(JSON.stringify({ ok: true, html }))
|
|
33
|
+
} catch (error) {
|
|
34
|
+
fail("RUV1100", error instanceof Error ? error.message : String(error), error?.stack)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectLayouts(appDir, routeDir) {
|
|
38
|
+
const layouts = []
|
|
39
|
+
let current = appDir
|
|
40
|
+
|
|
41
|
+
pushIfExists(layouts, path.join(current, "layout.tsx"))
|
|
42
|
+
|
|
43
|
+
const relative = path.relative(appDir, routeDir)
|
|
44
|
+
if (relative && !relative.startsWith("..")) {
|
|
45
|
+
for (const segment of relative.split(path.sep)) {
|
|
46
|
+
if (!segment) continue
|
|
47
|
+
current = path.join(current, segment)
|
|
48
|
+
pushIfExists(layouts, path.join(current, "layout.tsx"))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return layouts
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pushIfExists(collection, file) {
|
|
56
|
+
if (existsSync(file)) {
|
|
57
|
+
collection.push(file)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function bundleSsrModule(projectRoot, pageFile, layouts) {
|
|
62
|
+
const cacheDir = path.join(projectRoot, ".ruvyxa", "cache", "ssr")
|
|
63
|
+
await mkdir(cacheDir, { recursive: true })
|
|
64
|
+
|
|
65
|
+
const imports = [`import Page from ${JSON.stringify(toImportPath(pageFile))}`]
|
|
66
|
+
const wrappers = []
|
|
67
|
+
|
|
68
|
+
layouts.forEach((layoutFile, index) => {
|
|
69
|
+
imports.push(`import Layout${index} from ${JSON.stringify(toImportPath(layoutFile))}`)
|
|
70
|
+
wrappers.push(`Layout${index}`)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const moduleCode = `
|
|
74
|
+
import React from "react"
|
|
75
|
+
import { renderToString } from "react-dom/server"
|
|
76
|
+
${imports.join("\n")}
|
|
77
|
+
|
|
78
|
+
export async function render(ctx) {
|
|
79
|
+
let tree = React.createElement(Page, { params: ctx.params ?? {}, requestPath: ctx.path })
|
|
80
|
+
for (const Layout of [${wrappers.join(", ")}].reverse()) {
|
|
81
|
+
tree = React.createElement(Layout, null, tree)
|
|
82
|
+
}
|
|
83
|
+
return "<!doctype html>" + renderToString(tree)
|
|
84
|
+
}
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
const hash = createHash("sha256")
|
|
88
|
+
.update(moduleCode)
|
|
89
|
+
.update(pageFile)
|
|
90
|
+
.digest("hex")
|
|
91
|
+
.slice(0, 16)
|
|
92
|
+
const outfile = path.join(cacheDir, `${hash}.mjs`)
|
|
93
|
+
|
|
94
|
+
await build({
|
|
95
|
+
stdin: {
|
|
96
|
+
contents: moduleCode,
|
|
97
|
+
resolveDir: projectRoot,
|
|
98
|
+
sourcefile: "ruvyxa:ssr-entry.tsx",
|
|
99
|
+
loader: "tsx",
|
|
100
|
+
},
|
|
101
|
+
outfile,
|
|
102
|
+
bundle: true,
|
|
103
|
+
format: "esm",
|
|
104
|
+
platform: "node",
|
|
105
|
+
jsx: "automatic",
|
|
106
|
+
absWorkingDir: projectRoot,
|
|
107
|
+
external: ["react", "react-dom/server"],
|
|
108
|
+
plugins: [
|
|
109
|
+
{
|
|
110
|
+
name: "ruvyxa-css-empty-module",
|
|
111
|
+
setup(build) {
|
|
112
|
+
build.onLoad({ filter: /\.css$/ }, () => ({ contents: "", loader: "js" }))
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return outfile
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toImportPath(file) {
|
|
122
|
+
return path.resolve(file).replaceAll("\\", "/")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function fail(code, message, stack) {
|
|
126
|
+
process.stdout.write(JSON.stringify({ ok: false, code, message, stack }))
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|