@lomray/create-ssr-app 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 +144 -0
- package/bin/create-ssr-app.mjs +21 -0
- package/lib/commands.js +68 -0
- package/lib/errors.js +12 -0
- package/lib/files.js +79 -0
- package/lib/index.js +117 -0
- package/lib/options.js +101 -0
- package/lib/package-manager.js +21 -0
- package/lib/project.js +55 -0
- package/lib/source.js +59 -0
- package/lib/tar.js +187 -0
- package/lib/templates.js +19 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lomray Software
|
|
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,144 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Lomray-Software/vite-ssr-boost/prod/logo.png" width="160" alt="Lomray Vite SSR Boost" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">@lomray/create-ssr-app</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@lomray/create-ssr-app"><img src="https://img.shields.io/npm/v/@lomray/create-ssr-app" alt="npm version" /></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/Lomray-Software/create-ssr-app" alt="MIT license" /></a>
|
|
10
|
+
<a href="https://nodejs.org/"><img src="https://img.shields.io/node/v/@lomray/create-ssr-app" alt="Node.js version" /></a>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
Create a React application with Vite SSR Boost from the official Lomray templates.
|
|
14
|
+
Start with minimal SSR, the full reference app, a Fastify production server, or localization.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
Requires Node.js **22.12.0 or newer**.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm create @lomray/ssr-app@latest my-app
|
|
22
|
+
pnpm create @lomray/ssr-app my-app
|
|
23
|
+
yarn create @lomray/ssr-app my-app
|
|
24
|
+
bun create @lomray/ssr-app my-app
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You can also run `npx @lomray/create-ssr-app my-app` or the installed `create-ssr-app` binary.
|
|
28
|
+
For npm, put flags after `--` so npm forwards them to the scaffolder:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm create @lomray/ssr-app@latest my-app -- --template custom-server --no-install --no-git -y
|
|
32
|
+
npx @lomray/create-ssr-app my-app --template localization
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
On a terminal, missing values prompt for the directory, a numbered template choice, dependency
|
|
36
|
+
installation, and git initialization. Empty answers accept defaults. With `--yes` or non-TTY stdin,
|
|
37
|
+
the CLI uses flags and defaults without prompting. Package manager selection follows
|
|
38
|
+
`npm_config_user_agent`, falling back to npm.
|
|
39
|
+
|
|
40
|
+
## Options
|
|
41
|
+
|
|
42
|
+
| Argument / option | Default | Description |
|
|
43
|
+
| -------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `[directory]` | `my-ssr-app` | Destination directory. |
|
|
45
|
+
| `-t, --template <name>` | `minimal` | `full`, `minimal`, `custom-server`, or `localization`. |
|
|
46
|
+
| `--ref <branch\|tag\|sha>` | Template branch | Advanced: use any git ref in the template repository, overriding the template mapping and skipping the template prompt. |
|
|
47
|
+
| `--package-manager <name>` | Detected, otherwise `npm` | `npm`, `pnpm`, `yarn`, or `bun`. |
|
|
48
|
+
| `--no-install` | Install | Skip installation and print the install command in Next steps. |
|
|
49
|
+
| `--no-git` | Initialize git | Skip git initialization; remove `.husky/` and `scripts.prepare`. |
|
|
50
|
+
| `--force` | Off | Allow a non-empty destination, overwriting matching template files and retaining unrelated existing files. |
|
|
51
|
+
| `-y, --yes` | Off | Accept defaults without prompts. |
|
|
52
|
+
| `-h, --help` | Off | Print help. |
|
|
53
|
+
| `-v, --version` | Off | Print the package version. |
|
|
54
|
+
|
|
55
|
+
Git initialization is also skipped if git is unavailable or the destination is already inside a
|
|
56
|
+
repository; in both cases, `.husky/` and `scripts.prepare` are removed from the copied template.
|
|
57
|
+
Existing destination symlinks that conflict with template paths are rejected even with `--force`.
|
|
58
|
+
|
|
59
|
+
## Templates
|
|
60
|
+
|
|
61
|
+
These descriptions match the [template repository](https://github.com/Lomray-Software/vite-template/tree/prod).
|
|
62
|
+
See the [Vite SSR Boost documentation](https://lomray-software.github.io/vite-ssr-boost/) for development,
|
|
63
|
+
SSR behavior, and deployment.
|
|
64
|
+
|
|
65
|
+
| Template | Branch | What it shows |
|
|
66
|
+
| --------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| `full` | [`prod`](https://github.com/Lomray-Software/vite-template/tree/prod) | Streaming SSR, MobX, consistent Suspense, meta tags and route management |
|
|
68
|
+
| `minimal` | [`example/minimal`](https://github.com/Lomray-Software/vite-template/tree/example/minimal) | Six runtime dependencies, loaders, a lazy route with CSS, redirect, client-only route and 404, plus the SPA-to-SSR file diff |
|
|
69
|
+
| `custom-server` | [`example/custom-server`](https://github.com/Lomray-Software/vite-template/tree/example/custom-server) | Development through the managed CLI, production through an application-owned Fastify server with static assets, compression and Early Hints; dual export of the managed entry and a Fetch handler |
|
|
70
|
+
| `localization` | [`example/localization`](https://github.com/Lomray-Software/vite-template/tree/example/localization) | i18next with the language chosen on the server from the cookie or Accept-Language, transferred to the client before hydration, and a cookie-based switcher |
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
1. Downloads `https://codeload.github.com/Lomray-Software/vite-template/tar.gz/refs/heads/<branch>`
|
|
75
|
+
with Node's global `fetch`. For an explicit `--ref`, a branch endpoint returning 404 falls back to
|
|
76
|
+
`https://codeload.github.com/Lomray-Software/vite-template/tar.gz/<ref>` for tags and commits.
|
|
77
|
+
2. Decompresses with `node:zlib` and reads the tar archive using this package's own reader.
|
|
78
|
+
It supports ustar, pax, GNU long names, directories, regular files, and permission bits. It strips
|
|
79
|
+
the enclosing archive directory, skips global pax metadata and links, and rejects paths that
|
|
80
|
+
escape the destination. Source `.git` metadata is excluded. There are **zero runtime dependencies**.
|
|
81
|
+
3. Removes `.github/`, `renovate.json`, `CHANGELOG.md`, `LICENSE`, and `SECURITY.md` from the template.
|
|
82
|
+
When git initialization is skipped, it also removes `.husky/` and `scripts.prepare`.
|
|
83
|
+
It keeps `vercel.json`, `amplify.yml`, `Dockerfile`, and the other project files.
|
|
84
|
+
4. Sets `package.json`'s name to a valid npm name derived from the destination basename and its
|
|
85
|
+
version to `0.1.0`. It deletes `description`, `repository`, `homepage`, `bugs`, `author`, and
|
|
86
|
+
`keywords`, preserving `private` and other fields. The scaffolder does not edit lockfiles.
|
|
87
|
+
5. When enabled and available, runs `git init --initial-branch=main` and creates
|
|
88
|
+
`Initial commit from @lomray/create-ssr-app` before installing dependencies. The initial commit
|
|
89
|
+
bypasses hooks and signing; it uses the configured git identity, with `create-ssr-app` /
|
|
90
|
+
`create-ssr-app@localhost` as a fallback if none is configured.
|
|
91
|
+
6. Runs `npm ci` when npm is selected and `package-lock.json` exists; otherwise runs the selected
|
|
92
|
+
manager's `install` command. Prints each command, then Next steps with the selected manager's
|
|
93
|
+
`run develop` command and the documentation link. A package manager may update its own lockfile.
|
|
94
|
+
|
|
95
|
+
Colors use ANSI codes only on TTY output and are disabled whenever `NO_COLOR` is defined.
|
|
96
|
+
Exit codes are `0` for success, `1` for input, source, or command failures, and `2` for unexpected errors.
|
|
97
|
+
|
|
98
|
+
## Offline and proxies
|
|
99
|
+
|
|
100
|
+
Use a local `.tar.gz` archive (with an enclosing directory, like GitHub's archives) or a directory
|
|
101
|
+
containing the template's `package.json`:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
CREATE_SSR_APP_SOURCE=/path/to/template.tar.gz npx @lomray/create-ssr-app my-app --no-install -y
|
|
105
|
+
CREATE_SSR_APP_SOURCE=/path/to/vite-template npx @lomray/create-ssr-app my-app --no-install --no-git -y
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
This replaces the download regardless of `--template` or `--ref`. A directory is copied without
|
|
109
|
+
changing the source; symlinks and `.git` metadata are skipped. Use an already installed CLI or a local
|
|
110
|
+
checkout (`npm ci && npm run build`, then `node bin/create-ssr-app.mjs`) if npm itself is also offline.
|
|
111
|
+
|
|
112
|
+
The CLI does not implement proxy handling. `HTTPS_PROXY` alone is not automatically honored by
|
|
113
|
+
global `fetch`. On Node.js 22.23.2, a local proxy probe confirmed that `HTTPS_PROXY` alone was ignored
|
|
114
|
+
and `NODE_USE_ENV_PROXY=1` enabled proxy routing. Node added this opt-in for `fetch` in 22.21.0;
|
|
115
|
+
see the [Node.js release notes](https://github.com/nodejs/nodejs.org/blob/main/apps/site/pages/en/blog/release/v22.21.0.md).
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://proxy.example.com:8080 npx @lomray/create-ssr-app my-app
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
On older supported Node versions, upgrade to a runtime that honors proxy environment variables
|
|
122
|
+
or download the archive separately and use `CREATE_SSR_APP_SOURCE`.
|
|
123
|
+
|
|
124
|
+
## Contributing
|
|
125
|
+
|
|
126
|
+
Use the Node version in `.nvmrc` for the development tools:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
npm ci
|
|
130
|
+
npm run lint:check
|
|
131
|
+
npm run ts:check
|
|
132
|
+
npm test
|
|
133
|
+
npm run build
|
|
134
|
+
npm pack --dry-run
|
|
135
|
+
CREATE_SSR_APP_E2E=1 npm run test:e2e
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The opt-in e2e suite downloads all four GitHub templates and installs and builds the minimal app.
|
|
139
|
+
Releases use semantic-release: `prod` publishes to npm's `latest` channel and `staging` publishes
|
|
140
|
+
`beta` prereleases. CI expects `NPM_TOKEN` and `GITHUB_TOKEN` for releases.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
[MIT](LICENSE) — Lomray Software.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
4
|
+
|
|
5
|
+
if (major < 22 || (major === 22 && minor < 12)) {
|
|
6
|
+
console.error(
|
|
7
|
+
`Error: Unsupported Node.js ${process.versions.node}; @lomray/create-ssr-app requires Node.js >=22.12.0.`,
|
|
8
|
+
);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
} else {
|
|
11
|
+
try {
|
|
12
|
+
const { run } = await import('../lib/index.js');
|
|
13
|
+
|
|
14
|
+
process.exitCode = await run();
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
|
|
18
|
+
console.error(`Error: ${message.replace(/[\r\n\x1b]+/g, ' ').replace(/[.!]+$/, '')}.`);
|
|
19
|
+
process.exitCode = 2;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { UserError } from './errors.js';
|
|
4
|
+
import { statIfExists } from './files.js';
|
|
5
|
+
import { displayCommand } from './package-manager.js';
|
|
6
|
+
export const runCommand = async (command, cwd, log) => {
|
|
7
|
+
const [executable, ...args] = command;
|
|
8
|
+
if (!executable) {
|
|
9
|
+
throw new Error('Cannot run an empty command');
|
|
10
|
+
}
|
|
11
|
+
log(`Running: ${displayCommand(command)}`);
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(executable, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: 'inherit',
|
|
16
|
+
shell: process.platform === 'win32',
|
|
17
|
+
});
|
|
18
|
+
child.on('error', (error) => {
|
|
19
|
+
reject(new UserError(`Unable to run ${executable}: ${error.message}`));
|
|
20
|
+
});
|
|
21
|
+
child.on('exit', (code, signal) => {
|
|
22
|
+
if (code === 0) {
|
|
23
|
+
resolve();
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
reject(new UserError(`${displayCommand(command)} failed (${signal ? `signal ${signal}` : `exit code ${code}`})`));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
export const shouldInitializeGit = async (directory, enabled, log) => {
|
|
32
|
+
if (!enabled) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (spawnSync('git', ['--version'], { stdio: 'ignore' }).status !== 0) {
|
|
36
|
+
log('Git is unavailable; skipping git initialization.');
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
let ancestor = directory;
|
|
40
|
+
while (!(await statIfExists(ancestor))) {
|
|
41
|
+
ancestor = dirname(ancestor);
|
|
42
|
+
}
|
|
43
|
+
if (spawnSync('git', ['rev-parse', '--git-dir'], { cwd: ancestor, stdio: 'ignore' }).status === 0) {
|
|
44
|
+
log('The destination is already inside a repository; skipping git initialization.');
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
export const initializeGit = async (directory, log) => {
|
|
50
|
+
await runCommand(['git', 'init', '--initial-branch=main'], directory, log);
|
|
51
|
+
await runCommand(['git', 'add', '--all'], directory, log);
|
|
52
|
+
const config = [
|
|
53
|
+
'-c',
|
|
54
|
+
'commit.gpgsign=false',
|
|
55
|
+
'-c',
|
|
56
|
+
`core.hooksPath=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`,
|
|
57
|
+
];
|
|
58
|
+
for (const [key, fallback] of [
|
|
59
|
+
['user.name', 'create-ssr-app'],
|
|
60
|
+
['user.email', 'create-ssr-app@localhost'],
|
|
61
|
+
]) {
|
|
62
|
+
const value = spawnSync('git', ['config', '--get', key], { cwd: directory, encoding: 'utf8' });
|
|
63
|
+
if (!value.stdout?.trim()) {
|
|
64
|
+
config.push('-c', `${key}=${fallback}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await runCommand(['git', ...config, 'commit', '-m', 'Initial commit from @lomray/create-ssr-app'], directory, log);
|
|
68
|
+
};
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class UserError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export const errorCause = (error) => {
|
|
4
|
+
if (!(error instanceof Error)) {
|
|
5
|
+
return String(error);
|
|
6
|
+
}
|
|
7
|
+
const cause = error.cause instanceof Error ? `: ${error.cause.message}` : '';
|
|
8
|
+
return `${error.message}${cause}`;
|
|
9
|
+
};
|
|
10
|
+
export const errorSentence = (error) => `Error: ${errorCause(error)
|
|
11
|
+
.replace(/[\r\n\x1b]+/g, ' ')
|
|
12
|
+
.replace(/[.!]+$/, '')}.`;
|
package/lib/files.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmod, copyFile, lstat, mkdir, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { UserError } from './errors.js';
|
|
4
|
+
export const statIfExists = async (path) => {
|
|
5
|
+
try {
|
|
6
|
+
return await lstat(path);
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (error.code === 'ENOENT') {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
export const checkDestination = async (directory, force) => {
|
|
16
|
+
const info = await statIfExists(directory);
|
|
17
|
+
if (!info) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
21
|
+
throw new UserError(`Destination "${directory}" must be a directory, not a file or symlink`);
|
|
22
|
+
}
|
|
23
|
+
if (!force && (await readdir(directory)).length > 0) {
|
|
24
|
+
throw new UserError(`Directory "${directory}" is not empty; use --force to allow existing files`);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const collectEntries = async (source, target) => {
|
|
28
|
+
const entries = [];
|
|
29
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
30
|
+
// Repository metadata and links must never be imported from an offline checkout.
|
|
31
|
+
if (entry.name === '.git' || entry.isSymbolicLink()) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const sourcePath = join(source, entry.name);
|
|
35
|
+
const targetPath = join(target, entry.name);
|
|
36
|
+
const info = await lstat(sourcePath);
|
|
37
|
+
if (!info.isFile() && !info.isDirectory()) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const existing = await statIfExists(targetPath);
|
|
41
|
+
if (existing &&
|
|
42
|
+
(existing.isSymbolicLink() ||
|
|
43
|
+
existing.isDirectory() !== info.isDirectory() ||
|
|
44
|
+
(!existing.isDirectory() && !existing.isFile()))) {
|
|
45
|
+
throw new UserError(`Destination entry "${targetPath}" conflicts with the template or is a symlink`);
|
|
46
|
+
}
|
|
47
|
+
entries.push({
|
|
48
|
+
source: sourcePath,
|
|
49
|
+
target: targetPath,
|
|
50
|
+
directory: info.isDirectory(),
|
|
51
|
+
mode: info.mode & 0o777,
|
|
52
|
+
});
|
|
53
|
+
if (info.isDirectory()) {
|
|
54
|
+
entries.push(...(await collectEntries(sourcePath, targetPath)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return entries;
|
|
58
|
+
};
|
|
59
|
+
export const copyDirectory = async (source, target) => {
|
|
60
|
+
await checkDestination(target, true);
|
|
61
|
+
const entries = await collectEntries(source, target);
|
|
62
|
+
await mkdir(target, { recursive: true });
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (entry.directory) {
|
|
65
|
+
await mkdir(entry.target, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Replace the entry itself so an existing hard link cannot modify an outside file.
|
|
69
|
+
await rm(entry.target, { force: true });
|
|
70
|
+
await copyFile(entry.source, entry.target);
|
|
71
|
+
await chmod(entry.target, entry.mode);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries.reverse()) {
|
|
75
|
+
if (entry.directory) {
|
|
76
|
+
await chmod(entry.target, entry.mode);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { initializeGit, runCommand, shouldInitializeGit } from './commands.js';
|
|
7
|
+
import { UserError, errorSentence } from './errors.js';
|
|
8
|
+
import { checkDestination, copyDirectory, statIfExists } from './files.js';
|
|
9
|
+
import { parseArgs, resolveOptions } from './options.js';
|
|
10
|
+
import { developCommand, displayCommand, installCommand, quoteArgument, } from './package-manager.js';
|
|
11
|
+
import { postProcess, removeTemplateMetadata } from './project.js';
|
|
12
|
+
import { loadSource } from './source.js';
|
|
13
|
+
import { templates } from './templates.js';
|
|
14
|
+
const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
15
|
+
export const docsUrl = 'https://lomray-software.github.io/vite-ssr-boost/';
|
|
16
|
+
export const colorize = (text, code, isTTY = Boolean(process.stdout.isTTY), environment = process.env) => isTTY && environment.NO_COLOR === undefined ? `\u001B[${code}m${text}\u001B[0m` : text;
|
|
17
|
+
export const help = `${metadata.name} v${metadata.version}
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
npm create @lomray/ssr-app@latest [directory] [-- options]
|
|
21
|
+
npx @lomray/create-ssr-app [directory] [options]
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
-t, --template <name> full | minimal | custom-server | localization (default: minimal)
|
|
25
|
+
--ref <branch|tag|sha> Override the template branch with any template repository git ref
|
|
26
|
+
--package-manager <pm> npm | pnpm | yarn | bun (detected from npm_config_user_agent; otherwise npm)
|
|
27
|
+
--no-install Skip installing dependencies
|
|
28
|
+
--no-git Skip git initialization and remove Husky/prepare
|
|
29
|
+
--force Allow a non-empty directory; overwrite matching template files
|
|
30
|
+
-y, --yes Accept defaults without prompts
|
|
31
|
+
-h, --help Show this help
|
|
32
|
+
-v, --version Show the version
|
|
33
|
+
|
|
34
|
+
The default directory is my-ssr-app. Prompts run only when stdin is a TTY and --yes is absent.
|
|
35
|
+
CREATE_SSR_APP_SOURCE=<directory|archive.tar.gz> uses a local template instead of GitHub.
|
|
36
|
+
Requires Node.js >=22.12.0.
|
|
37
|
+
Documentation: ${docsUrl}`;
|
|
38
|
+
export const nextSteps = (options, hasNpmLockfile) => {
|
|
39
|
+
const directory = options.directory.startsWith('-')
|
|
40
|
+
? `./${options.directory}`
|
|
41
|
+
: options.directory;
|
|
42
|
+
const commands = [`cd ${quoteArgument(directory)}`];
|
|
43
|
+
if (!options.install) {
|
|
44
|
+
commands.push(displayCommand(installCommand(options.packageManager, hasNpmLockfile)));
|
|
45
|
+
}
|
|
46
|
+
commands.push(displayCommand(developCommand(options.packageManager)));
|
|
47
|
+
return `\nNext steps:\n${commands.map((command) => ` ${command}`).join('\n')}\n\nDocumentation: ${docsUrl}`;
|
|
48
|
+
};
|
|
49
|
+
export const scaffold = async (options, log = console.log, source = process.env.CREATE_SSR_APP_SOURCE) => {
|
|
50
|
+
const directory = resolve(options.directory);
|
|
51
|
+
await checkDestination(directory, options.force);
|
|
52
|
+
log(`Template: ${options.template} (${options.ref ?? templates[options.template].branch}${options.ref ? ', --ref override' : ''})`);
|
|
53
|
+
log(`Directory: ${options.directory}`);
|
|
54
|
+
const shouldUseGit = await shouldInitializeGit(directory, options.git, log);
|
|
55
|
+
const temporary = await mkdtemp(join(tmpdir(), 'create-ssr-app-'));
|
|
56
|
+
try {
|
|
57
|
+
const staging = join(temporary, 'template');
|
|
58
|
+
await loadSource(staging, options.ref ?? templates[options.template].branch, Boolean(options.ref), source);
|
|
59
|
+
await postProcess(staging, directory, shouldUseGit);
|
|
60
|
+
// Recheck after the download in case another process populated the destination.
|
|
61
|
+
await checkDestination(directory, options.force);
|
|
62
|
+
await copyDirectory(staging, directory);
|
|
63
|
+
await removeTemplateMetadata(directory, shouldUseGit);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await rm(temporary, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
if (shouldUseGit) {
|
|
69
|
+
await initializeGit(directory, log);
|
|
70
|
+
}
|
|
71
|
+
const hasNpmLockfile = Boolean(await statIfExists(join(directory, 'package-lock.json')));
|
|
72
|
+
if (options.install) {
|
|
73
|
+
await runCommand(installCommand(options.packageManager, hasNpmLockfile), directory, log);
|
|
74
|
+
}
|
|
75
|
+
log(colorize('\nProject created.', 32));
|
|
76
|
+
log(nextSteps(options, hasNpmLockfile));
|
|
77
|
+
};
|
|
78
|
+
export const run = async (args = process.argv.slice(2)) => {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = parseArgs(args, process.env.npm_config_user_agent);
|
|
81
|
+
if (parsed.options.help) {
|
|
82
|
+
console.log(help);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
if (parsed.options.version) {
|
|
86
|
+
console.log(metadata.version);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
console.log(colorize(`${metadata.name} v${metadata.version}\n`, 36));
|
|
90
|
+
let { options } = parsed;
|
|
91
|
+
if (process.stdin.isTTY && !options.yes) {
|
|
92
|
+
const reader = createInterface({ input: process.stdin, output: process.stdout });
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const cancel = () => controller.abort();
|
|
95
|
+
reader.on('SIGINT', cancel);
|
|
96
|
+
reader.on('close', cancel);
|
|
97
|
+
try {
|
|
98
|
+
options = await resolveOptions(parsed, true, (question) => reader.question(question, { signal: controller.signal }));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (controller.signal.aborted) {
|
|
102
|
+
throw new UserError('Project creation was cancelled');
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
reader.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await scaffold(options);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.error(errorSentence(error));
|
|
115
|
+
return error instanceof UserError ? 1 : 2;
|
|
116
|
+
}
|
|
117
|
+
};
|
package/lib/options.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseArgs as parseNodeArgs } from 'node:util';
|
|
2
|
+
import { UserError, errorCause } from './errors.js';
|
|
3
|
+
import { detectPackageManager, isPackageManager } from './package-manager.js';
|
|
4
|
+
import { isTemplate, templates } from './templates.js';
|
|
5
|
+
export const parseArgs = (args, userAgent) => {
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = parseNodeArgs({
|
|
9
|
+
args,
|
|
10
|
+
allowPositionals: true,
|
|
11
|
+
strict: true,
|
|
12
|
+
options: {
|
|
13
|
+
template: { type: 'string', short: 't' },
|
|
14
|
+
ref: { type: 'string' },
|
|
15
|
+
'package-manager': { type: 'string' },
|
|
16
|
+
'no-install': { type: 'boolean' },
|
|
17
|
+
'no-git': { type: 'boolean' },
|
|
18
|
+
force: { type: 'boolean' },
|
|
19
|
+
yes: { type: 'boolean', short: 'y' },
|
|
20
|
+
help: { type: 'boolean', short: 'h' },
|
|
21
|
+
version: { type: 'boolean', short: 'v' },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new UserError(errorCause(error));
|
|
27
|
+
}
|
|
28
|
+
const { values, positionals } = parsed;
|
|
29
|
+
const template = values.template ?? 'minimal';
|
|
30
|
+
const packageManager = values['package-manager'] ?? detectPackageManager(userAgent);
|
|
31
|
+
if (positionals.length > 1) {
|
|
32
|
+
throw new UserError('Expected at most one project directory');
|
|
33
|
+
}
|
|
34
|
+
if (positionals[0] !== undefined && !positionals[0].trim()) {
|
|
35
|
+
throw new UserError('The project directory cannot be empty');
|
|
36
|
+
}
|
|
37
|
+
if (!isTemplate(template)) {
|
|
38
|
+
throw new UserError(`Unknown template "${template}"; choose ${Object.keys(templates).join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
if (!isPackageManager(packageManager)) {
|
|
41
|
+
throw new UserError(`Unknown package manager "${packageManager}"; choose npm, pnpm, yarn or bun`);
|
|
42
|
+
}
|
|
43
|
+
if (values.ref !== undefined && (!values.ref.trim() || /[\x00-\x20\x7f]/u.test(values.ref))) {
|
|
44
|
+
throw new UserError('The template ref must be a non-empty git branch, tag or SHA without spaces');
|
|
45
|
+
}
|
|
46
|
+
const provided = new Set(Object.keys(values));
|
|
47
|
+
if (positionals[0] !== undefined) {
|
|
48
|
+
provided.add('directory');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
options: {
|
|
52
|
+
directory: positionals[0] ?? 'my-ssr-app',
|
|
53
|
+
template,
|
|
54
|
+
ref: values.ref,
|
|
55
|
+
packageManager,
|
|
56
|
+
install: !values['no-install'],
|
|
57
|
+
git: !values['no-git'],
|
|
58
|
+
force: values.force ?? false,
|
|
59
|
+
yes: values.yes ?? false,
|
|
60
|
+
help: values.help ?? false,
|
|
61
|
+
version: values.version ?? false,
|
|
62
|
+
},
|
|
63
|
+
provided,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const confirm = async (ask, question) => {
|
|
67
|
+
const answer = (await ask(`${question} (Y/n) `)).trim().toLowerCase();
|
|
68
|
+
if (['', 'y', 'yes'].includes(answer)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (['n', 'no'].includes(answer)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
throw new UserError('Please answer yes or no');
|
|
75
|
+
};
|
|
76
|
+
export const resolveOptions = async ({ options: defaults, provided }, isTTY, ask) => {
|
|
77
|
+
const options = { ...defaults };
|
|
78
|
+
if (!isTTY || options.yes || options.help || options.version) {
|
|
79
|
+
return options;
|
|
80
|
+
}
|
|
81
|
+
if (!provided.has('directory')) {
|
|
82
|
+
options.directory = (await ask('Project directory (my-ssr-app): ')).trim() || options.directory;
|
|
83
|
+
}
|
|
84
|
+
if (!provided.has('template') && !provided.has('ref')) {
|
|
85
|
+
const names = Object.keys(templates);
|
|
86
|
+
const choices = names.map((name, index) => ` ${index + 1}. ${name}: ${templates[name].description}`);
|
|
87
|
+
const answer = (await ask(`Templates:\n${choices.join('\n')}\nTemplate (2, minimal): `)).trim();
|
|
88
|
+
const chosen = /^\d+$/u.test(answer) ? names[Number(answer) - 1] : answer || options.template;
|
|
89
|
+
if (!chosen || !isTemplate(chosen)) {
|
|
90
|
+
throw new UserError(`Unknown template "${answer}"; choose a template name or number 1–4`);
|
|
91
|
+
}
|
|
92
|
+
options.template = chosen;
|
|
93
|
+
}
|
|
94
|
+
if (!provided.has('no-install')) {
|
|
95
|
+
options.install = await confirm(ask, 'Install dependencies?');
|
|
96
|
+
}
|
|
97
|
+
if (!provided.has('no-git')) {
|
|
98
|
+
options.git = await confirm(ask, 'Initialize git?');
|
|
99
|
+
}
|
|
100
|
+
return options;
|
|
101
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const packageManagers = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
2
|
+
export const isPackageManager = (value) => packageManagers.some((manager) => manager === value);
|
|
3
|
+
export const detectPackageManager = (userAgent = '') => {
|
|
4
|
+
const manager = userAgent.split('/')[0] ?? '';
|
|
5
|
+
return isPackageManager(manager) ? manager : 'npm';
|
|
6
|
+
};
|
|
7
|
+
export const installCommand = (manager, hasNpmLockfile) => [
|
|
8
|
+
manager,
|
|
9
|
+
manager === 'npm' && hasNpmLockfile ? 'ci' : 'install',
|
|
10
|
+
];
|
|
11
|
+
export const developCommand = (manager) => [manager, 'run', 'develop'];
|
|
12
|
+
export const quoteArgument = (value) => {
|
|
13
|
+
if (/^[\w@%+=:,./-]+$/u.test(value)) {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
if (process.platform === 'win32') {
|
|
17
|
+
return `"${value.replaceAll('"', '\\"')}"`;
|
|
18
|
+
}
|
|
19
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
20
|
+
};
|
|
21
|
+
export const displayCommand = (command) => command.map(quoteArgument).join(' ');
|
package/lib/project.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join, resolve } from 'node:path';
|
|
3
|
+
import { UserError, errorCause } from './errors.js';
|
|
4
|
+
export const packageName = (directory) => {
|
|
5
|
+
const name = basename(resolve(directory))
|
|
6
|
+
.normalize('NFKD')
|
|
7
|
+
.replace(/[\u0300-\u036f]/gu, '')
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9._-]+/gu, '-')
|
|
10
|
+
.replace(/^[._-]+|[._-]+$/gu, '')
|
|
11
|
+
.slice(0, 214);
|
|
12
|
+
if (!name) {
|
|
13
|
+
return 'my-ssr-app';
|
|
14
|
+
}
|
|
15
|
+
return ['node_modules', 'favicon.ico'].includes(name) ? `my-${name}` : name;
|
|
16
|
+
};
|
|
17
|
+
export const rewritePackage = (manifest, directory, git) => {
|
|
18
|
+
const result = {
|
|
19
|
+
...manifest,
|
|
20
|
+
name: packageName(directory),
|
|
21
|
+
version: '0.1.0',
|
|
22
|
+
};
|
|
23
|
+
for (const key of ['description', 'repository', 'homepage', 'bugs', 'author', 'keywords']) {
|
|
24
|
+
delete result[key];
|
|
25
|
+
}
|
|
26
|
+
if (!git && result.scripts && typeof result.scripts === 'object') {
|
|
27
|
+
const scripts = { ...result.scripts };
|
|
28
|
+
delete scripts.prepare;
|
|
29
|
+
result.scripts = scripts;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
export const postProcess = async (directory, nameFrom, git) => {
|
|
34
|
+
const manifestPath = join(directory, 'package.json');
|
|
35
|
+
let manifest;
|
|
36
|
+
try {
|
|
37
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new UserError(`Unable to read the template package.json: ${errorCause(error)}`);
|
|
41
|
+
}
|
|
42
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
43
|
+
throw new UserError('The template package.json must contain a JSON object');
|
|
44
|
+
}
|
|
45
|
+
const rewritten = rewritePackage(manifest, nameFrom, git);
|
|
46
|
+
await writeFile(manifestPath, `${JSON.stringify(rewritten, null, 2)}\n`);
|
|
47
|
+
await removeTemplateMetadata(directory, git);
|
|
48
|
+
};
|
|
49
|
+
export const removeTemplateMetadata = async (directory, git) => {
|
|
50
|
+
const removals = ['.github', 'renovate.json', 'CHANGELOG.md', 'LICENSE', 'SECURITY.md'];
|
|
51
|
+
if (!git) {
|
|
52
|
+
removals.push('.husky');
|
|
53
|
+
}
|
|
54
|
+
await Promise.all(removals.map((file) => rm(join(directory, file), { recursive: true, force: true })));
|
|
55
|
+
};
|
package/lib/source.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { gunzip } from 'node:zlib';
|
|
5
|
+
import { UserError, errorCause } from './errors.js';
|
|
6
|
+
import { copyDirectory, statIfExists } from './files.js';
|
|
7
|
+
import { extractTar } from './tar.js';
|
|
8
|
+
const decompress = promisify(gunzip);
|
|
9
|
+
const archiveBase = 'https://codeload.github.com/Lomray-Software/vite-template/tar.gz';
|
|
10
|
+
export const downloadArchive = async (ref, allowRefFallback) => {
|
|
11
|
+
const encodedRef = ref.split('/').map(encodeURIComponent).join('/');
|
|
12
|
+
const urls = [`${archiveBase}/refs/heads/${encodedRef}`];
|
|
13
|
+
if (allowRefFallback) {
|
|
14
|
+
urls.push(`${archiveBase}/${encodedRef}`);
|
|
15
|
+
}
|
|
16
|
+
for (const [index, url] of urls.entries()) {
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
|
20
|
+
if (response.ok) {
|
|
21
|
+
return Buffer.from(await response.arrayBuffer());
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new UserError(`Unable to download template ref "${ref}": network failure (${errorCause(error)})`);
|
|
26
|
+
}
|
|
27
|
+
await response.body?.cancel();
|
|
28
|
+
if (response.status !== 404 || index === urls.length - 1) {
|
|
29
|
+
throw new UserError(`Unable to download template ref "${ref}": HTTP ${response.status} ${response.statusText}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new UserError(`Template ref "${ref}" was not found`);
|
|
33
|
+
};
|
|
34
|
+
export const loadSource = async (destination, ref, allowRefFallback, source) => {
|
|
35
|
+
let compressed;
|
|
36
|
+
if (source) {
|
|
37
|
+
const path = resolve(source);
|
|
38
|
+
const info = await statIfExists(path);
|
|
39
|
+
if (!info || (!info.isDirectory() && !info.isFile())) {
|
|
40
|
+
throw new UserError(`Offline source "${path}" must be a .tar.gz file or directory`);
|
|
41
|
+
}
|
|
42
|
+
if (info.isDirectory()) {
|
|
43
|
+
await copyDirectory(path, destination);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
compressed = await readFile(path);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
compressed = await downloadArchive(ref, allowRefFallback);
|
|
50
|
+
}
|
|
51
|
+
let archive;
|
|
52
|
+
try {
|
|
53
|
+
archive = await decompress(compressed);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new UserError(`Unable to decompress template archive: ${errorCause(error)}`);
|
|
57
|
+
}
|
|
58
|
+
await extractTar(archive, destination);
|
|
59
|
+
};
|
package/lib/tar.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { chmod, mkdir, open, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { UserError } from './errors.js';
|
|
5
|
+
import { statIfExists } from './files.js';
|
|
6
|
+
const BLOCK_SIZE = 512;
|
|
7
|
+
const readString = (buffer, start, length) => buffer
|
|
8
|
+
.subarray(start, start + length)
|
|
9
|
+
.toString('utf8')
|
|
10
|
+
.split('\0')[0] ?? '';
|
|
11
|
+
const readNumber = (buffer, start, length) => {
|
|
12
|
+
const field = buffer.subarray(start, start + length);
|
|
13
|
+
if (field[0] & 0x80) {
|
|
14
|
+
let value = BigInt(field[0] & 0x7f);
|
|
15
|
+
for (const byte of field.subarray(1)) {
|
|
16
|
+
value = value * 256n + BigInt(byte);
|
|
17
|
+
}
|
|
18
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
19
|
+
throw new UserError('Invalid tar archive: numeric field is too large');
|
|
20
|
+
}
|
|
21
|
+
return Number(value);
|
|
22
|
+
}
|
|
23
|
+
const text = readString(buffer, start, length).trim();
|
|
24
|
+
if (text && !/^[0-7]+$/u.test(text)) {
|
|
25
|
+
throw new UserError('Invalid tar archive: expected an octal numeric field');
|
|
26
|
+
}
|
|
27
|
+
const value = Number.parseInt(text || '0', 8);
|
|
28
|
+
if (!Number.isSafeInteger(value)) {
|
|
29
|
+
throw new UserError('Invalid tar archive: numeric field is too large');
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
};
|
|
33
|
+
const checkHeader = (header) => {
|
|
34
|
+
let checksum = 0;
|
|
35
|
+
for (const [index, byte] of header.entries()) {
|
|
36
|
+
checksum += index >= 148 && index < 156 ? 32 : byte;
|
|
37
|
+
}
|
|
38
|
+
if (checksum !== readNumber(header, 148, 8)) {
|
|
39
|
+
throw new UserError('Invalid tar archive: header checksum does not match');
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const parsePax = (data) => {
|
|
43
|
+
const fields = Object.create(null);
|
|
44
|
+
let offset = 0;
|
|
45
|
+
while (offset < data.length) {
|
|
46
|
+
const space = data.indexOf(32, offset);
|
|
47
|
+
const sizeText = data.subarray(offset, space).toString('ascii');
|
|
48
|
+
const size = Number(sizeText);
|
|
49
|
+
if (space < offset ||
|
|
50
|
+
!/^\d+$/u.test(sizeText) ||
|
|
51
|
+
!Number.isSafeInteger(size) ||
|
|
52
|
+
size <= space - offset + 1 ||
|
|
53
|
+
offset + size > data.length ||
|
|
54
|
+
data[offset + size - 1] !== 10) {
|
|
55
|
+
throw new UserError('Invalid tar archive: malformed pax record');
|
|
56
|
+
}
|
|
57
|
+
const record = data.subarray(space + 1, offset + size - 1).toString('utf8');
|
|
58
|
+
const equals = record.indexOf('=');
|
|
59
|
+
if (equals < 1) {
|
|
60
|
+
throw new UserError('Invalid tar archive: malformed pax attribute');
|
|
61
|
+
}
|
|
62
|
+
fields[record.slice(0, equals)] = record.slice(equals + 1);
|
|
63
|
+
offset += size;
|
|
64
|
+
}
|
|
65
|
+
return fields;
|
|
66
|
+
};
|
|
67
|
+
export const entryPath = (root, name) => {
|
|
68
|
+
if (name.includes('\\') ||
|
|
69
|
+
name.includes('\0') ||
|
|
70
|
+
name.startsWith('/') ||
|
|
71
|
+
/^[a-z]:/iu.test(name)) {
|
|
72
|
+
throw new UserError(`Unsafe tar entry "${name}": path escapes the target directory`);
|
|
73
|
+
}
|
|
74
|
+
const components = name.split('/').filter((component) => component && component !== '.');
|
|
75
|
+
if (components.includes('..')) {
|
|
76
|
+
throw new UserError(`Unsafe tar entry "${name}": path escapes the target directory`);
|
|
77
|
+
}
|
|
78
|
+
components.shift();
|
|
79
|
+
if (components.length === 0 || components.includes('.git')) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const destination = resolve(root, ...components);
|
|
83
|
+
const withinRoot = relative(resolve(root), destination);
|
|
84
|
+
if (withinRoot === '..' || withinRoot.startsWith(`..${sep}`) || isAbsolute(withinRoot)) {
|
|
85
|
+
throw new UserError(`Unsafe tar entry "${name}": path escapes the target directory`);
|
|
86
|
+
}
|
|
87
|
+
return destination;
|
|
88
|
+
};
|
|
89
|
+
const ensureDirectory = async (root, directory) => {
|
|
90
|
+
const parts = relative(root, directory).split(sep).filter(Boolean);
|
|
91
|
+
let current = root;
|
|
92
|
+
for (const part of ['', ...parts]) {
|
|
93
|
+
current = join(current, part);
|
|
94
|
+
const info = await statIfExists(current);
|
|
95
|
+
if (info && (!info.isDirectory() || info.isSymbolicLink())) {
|
|
96
|
+
throw new UserError(`Unsafe tar destination "${current}": expected a directory without symlinks`);
|
|
97
|
+
}
|
|
98
|
+
await mkdir(current, { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
/** Read ustar, pax and GNU long-name archives, stripping their enclosing directory. */
|
|
102
|
+
export const extractTar = async (archive, directory) => {
|
|
103
|
+
const root = resolve(directory);
|
|
104
|
+
const directoryModes = new Map();
|
|
105
|
+
let pax = {};
|
|
106
|
+
let longName;
|
|
107
|
+
let offset = 0;
|
|
108
|
+
let hasEnded = false;
|
|
109
|
+
await ensureDirectory(root, root);
|
|
110
|
+
while (offset + BLOCK_SIZE <= archive.length) {
|
|
111
|
+
const header = archive.subarray(offset, offset + BLOCK_SIZE);
|
|
112
|
+
if (header.every((byte) => byte === 0)) {
|
|
113
|
+
hasEnded = true;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
checkHeader(header);
|
|
117
|
+
const type = readString(header, 156, 1);
|
|
118
|
+
const isExtended = ['x', 'g', 'L', 'K'].includes(type);
|
|
119
|
+
const headerSize = readNumber(header, 124, 12);
|
|
120
|
+
const size = !isExtended && pax.size !== undefined ? Number(pax.size) : headerSize;
|
|
121
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
122
|
+
throw new UserError('Invalid tar archive: invalid entry size');
|
|
123
|
+
}
|
|
124
|
+
const start = offset + BLOCK_SIZE;
|
|
125
|
+
const next = start + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE;
|
|
126
|
+
if (next > archive.length) {
|
|
127
|
+
throw new UserError('Invalid tar archive: truncated entry');
|
|
128
|
+
}
|
|
129
|
+
const data = archive.subarray(start, start + size);
|
|
130
|
+
offset = next;
|
|
131
|
+
if (type === 'g') {
|
|
132
|
+
// GitHub emits pax_global_header with repository metadata, not project files.
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (type === 'x') {
|
|
136
|
+
pax = { ...pax, ...parsePax(data) };
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (type === 'L') {
|
|
140
|
+
longName = readString(data, 0, data.length);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (type === 'K') {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const prefix = readString(header, 257, 6) === 'ustar' ? readString(header, 345, 155) : '';
|
|
147
|
+
const headerName = [prefix, readString(header, 0, 100)].filter(Boolean).join('/');
|
|
148
|
+
const name = pax.path ?? longName ?? headerName;
|
|
149
|
+
pax = {};
|
|
150
|
+
longName = undefined;
|
|
151
|
+
// Skip links, devices, FIFOs and other non-file entries without following them.
|
|
152
|
+
if (!['', '0', '5'].includes(type) || name === 'pax_global_header') {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const destination = entryPath(root, name);
|
|
156
|
+
if (!destination) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const mode = readNumber(header, 100, 8) & 0o777;
|
|
160
|
+
if (type === '5' || name.endsWith('/')) {
|
|
161
|
+
await ensureDirectory(root, destination);
|
|
162
|
+
directoryModes.set(destination, mode);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
await ensureDirectory(root, dirname(destination));
|
|
166
|
+
const existing = await statIfExists(destination);
|
|
167
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink())) {
|
|
168
|
+
throw new UserError(`Unsafe tar destination "${destination}": expected a regular file`);
|
|
169
|
+
}
|
|
170
|
+
await rm(destination, { force: true });
|
|
171
|
+
const file = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
|
|
172
|
+
try {
|
|
173
|
+
await file.writeFile(data);
|
|
174
|
+
await file.chmod(mode);
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
await file.close();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!hasEnded) {
|
|
181
|
+
throw new UserError('Invalid tar archive: missing end-of-archive block');
|
|
182
|
+
}
|
|
183
|
+
const deepestFirst = [...directoryModes].sort(([left], [right]) => right.length - left.length);
|
|
184
|
+
for (const [path, mode] of deepestFirst) {
|
|
185
|
+
await chmod(path, mode);
|
|
186
|
+
}
|
|
187
|
+
};
|
package/lib/templates.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const templates = {
|
|
2
|
+
full: {
|
|
3
|
+
branch: 'prod',
|
|
4
|
+
description: 'Streaming SSR, MobX, consistent Suspense, meta tags and route management',
|
|
5
|
+
},
|
|
6
|
+
minimal: {
|
|
7
|
+
branch: 'example/minimal',
|
|
8
|
+
description: 'Six runtime dependencies, loaders, a lazy route with CSS, redirect, client-only route and 404, plus the SPA-to-SSR file diff',
|
|
9
|
+
},
|
|
10
|
+
'custom-server': {
|
|
11
|
+
branch: 'example/custom-server',
|
|
12
|
+
description: 'Development through the managed CLI, production through an application-owned Fastify server with static assets, compression and Early Hints; dual export of the managed entry and a Fetch handler',
|
|
13
|
+
},
|
|
14
|
+
localization: {
|
|
15
|
+
branch: 'example/localization',
|
|
16
|
+
description: 'i18next with the language chosen on the server from the cookie or Accept-Language, transferred to the client before hydration, and a cookie-based switcher',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export const isTemplate = (value) => Object.hasOwn(templates, value);
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lomray/create-ssr-app",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create a Lomray Vite SSR application from the official templates.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-ssr-app": "bin/create-ssr-app.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.12.0"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"lib",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/Lomray-Software/create-ssr-app.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/Lomray-Software/create-ssr-app",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/Lomray-Software/create-ssr-app/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"vite",
|
|
31
|
+
"ssr",
|
|
32
|
+
"react",
|
|
33
|
+
"scaffold",
|
|
34
|
+
"create",
|
|
35
|
+
"lomray"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"lint:check": "eslint . --max-warnings=0 && prettier --check .",
|
|
41
|
+
"lint:format": "eslint . --fix && prettier --write .",
|
|
42
|
+
"ts:check": "tsc --noEmit",
|
|
43
|
+
"test": "vitest run --exclude tests/e2e.test.ts",
|
|
44
|
+
"test:e2e": "npm run build && vitest run tests/e2e.test.ts --reporter=verbose",
|
|
45
|
+
"prepare": "husky",
|
|
46
|
+
"prepack": "npm run build"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@commitlint/cli": "^21.2.2",
|
|
50
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
51
|
+
"@lomray/eslint-config": "^7.0.0",
|
|
52
|
+
"@lomray/prettier-config": "^3.0.0",
|
|
53
|
+
"@types/node": "^22.20.1",
|
|
54
|
+
"eslint": "^10.10.0",
|
|
55
|
+
"globals": "^17.12.0",
|
|
56
|
+
"husky": "^9.1.7",
|
|
57
|
+
"prettier": "^3.9.6",
|
|
58
|
+
"semantic-release": "^25.0.9",
|
|
59
|
+
"typescript": "~6.0.3",
|
|
60
|
+
"typescript-eslint": "^8.69.0",
|
|
61
|
+
"vitest": "^5.0.0"
|
|
62
|
+
}
|
|
63
|
+
}
|