orionplace-nuxt 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/orionplace-nuxt.mjs +68 -0
- package/package.json +52 -0
- package/runtime/api.mjs +11 -0
- package/runtime/domain.mjs +9 -0
- package/runtime/marketplace.mjs +211 -0
- package/src/module.d.ts +3 -0
- package/src/module.mjs +49 -0
- package/src/project-readme.mjs +180 -0
- package/src/setup.mjs +188 -0
- package/src/validation.mjs +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Orionplace contributors
|
|
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,170 @@
|
|
|
1
|
+
# orionplace-nuxt
|
|
2
|
+
|
|
3
|
+
Set up a Nuxt 4 storefront for your personal Orionplace marketplace API.
|
|
4
|
+
The package includes a Nuxt module, a setup CLI, and commented server source
|
|
5
|
+
that is copied into your project alongside an integration README.
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22.12+ and a Node server deployment. Static-only and edge
|
|
8
|
+
hosting are not supported by this filesystem-based integration.
|
|
9
|
+
|
|
10
|
+
## New project
|
|
11
|
+
|
|
12
|
+
Your personal backend domain is already provided in your account at
|
|
13
|
+
[orionmarket.place](https://orionmarket.place). You can change it there.
|
|
14
|
+
Use that domain below and the public domain of your own storefront.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
mkdir my-marketplace
|
|
18
|
+
cd my-marketplace
|
|
19
|
+
npm install orionplace-nuxt
|
|
20
|
+
npx orionplace-nuxt set backend your-project.orionplace.app
|
|
21
|
+
npx orionplace-nuxt add frontend shop.example.com
|
|
22
|
+
npm install
|
|
23
|
+
npm run dev
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The first setup command creates the Nuxt configuration and app if needed.
|
|
27
|
+
The final `npm install` installs the dependencies added to `package.json`.
|
|
28
|
+
The backend command accepts either a hostname or its full HTTPS `/api` URL.
|
|
29
|
+
The frontend command accepts a public hostname or its root HTTPS URL.
|
|
30
|
+
|
|
31
|
+
You can also scaffold before installing:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx orionplace-nuxt init my-marketplace
|
|
35
|
+
cd my-marketplace
|
|
36
|
+
npm install
|
|
37
|
+
npx orionplace-nuxt set backend your-project.orionplace.app
|
|
38
|
+
npx orionplace-nuxt add frontend shop.example.com
|
|
39
|
+
npm run dev
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Existing Nuxt 4 project
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install orionplace-nuxt
|
|
46
|
+
npx orionplace-nuxt init --dry-run
|
|
47
|
+
npx orionplace-nuxt set backend your-project.orionplace.app
|
|
48
|
+
npx orionplace-nuxt add frontend shop.example.com
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The CLI preserves unrelated Nuxt settings, scripts, and environment variables.
|
|
52
|
+
It supports a standard root `nuxt.config.ts`, `.js`, or `.mjs` with a literal
|
|
53
|
+
configuration object and module array. Resolve existing `/api` proxy or
|
|
54
|
+
catch-all conflicts before setup. Custom source/server directories, Nuxt layers,
|
|
55
|
+
and dynamic configuration expressions require manual integration.
|
|
56
|
+
|
|
57
|
+
No install or postinstall hook changes your application. Only explicitly running
|
|
58
|
+
the CLI writes files. Use `--cwd <directory>` to select a project and `--dry-run`
|
|
59
|
+
to inspect planned changes without writing them.
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx orionplace-nuxt init [directory]
|
|
65
|
+
npx orionplace-nuxt set backend your-project.orionplace.app
|
|
66
|
+
npx orionplace-nuxt add frontend shop.example.com
|
|
67
|
+
npx orionplace-nuxt add frontend www.shop.example.com
|
|
68
|
+
npx orionplace-nuxt remove frontend www.shop.example.com
|
|
69
|
+
npx orionplace-nuxt set frontend shop.example.com
|
|
70
|
+
npx orionplace-nuxt --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Either domain command can run first. Both settings are needed for a usable mapping.
|
|
74
|
+
After setup, these npm script aliases are also available:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm run orionplace:backend -- your-project.orionplace.app
|
|
78
|
+
npm run orionplace:add-frontend -- shop.example.com
|
|
79
|
+
npm run orionplace:remove-frontend -- www.shop.example.com
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`npm set` belongs to npm's own configuration system; use the commands above.
|
|
83
|
+
These commands configure local integration files. They do not provision a backend,
|
|
84
|
+
change your account, create DNS records, configure TLS, or deploy a VPS.
|
|
85
|
+
|
|
86
|
+
## Generated files
|
|
87
|
+
|
|
88
|
+
| Path | Purpose |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `nuxt.config.ts` | Registers the module; an existing supported config keeps its filename. |
|
|
91
|
+
| `orionplace/runtime/marketplace.mjs` | Commented domain lookup and HTTP transport. |
|
|
92
|
+
| `orionplace/runtime/api.mjs` | Nitro handler registered for `/api/**`. |
|
|
93
|
+
| `orionplace/runtime/domain.mjs` | Domain resolution middleware. |
|
|
94
|
+
| `orionplace/README.md` | Your settings, API examples, file explanations, verification and VPS deployment. |
|
|
95
|
+
| `orionplace/settings.json` | Domain values selected by the CLI. |
|
|
96
|
+
| `orionplace/.generated.json` | File hashes used to preserve your edits on subsequent setup. |
|
|
97
|
+
| `sitemaps/shop.example.com.cfg` | One line: `https://your-project.orionplace.app/api`. |
|
|
98
|
+
| `.env` | `MIMIC_FRONT_DOMAIN="shop.example.com"` for localhost. |
|
|
99
|
+
|
|
100
|
+
The module executes the generated local runtime files. You can read and customize
|
|
101
|
+
them. Later setup runs preserve edited runtime files and the project README,
|
|
102
|
+
reporting which files were preserved. Unchanged generated files can be refreshed
|
|
103
|
+
by running `init` after upgrading the package. A manually edited domain file is
|
|
104
|
+
never silently overwritten. Keep the hash manifest in version control.
|
|
105
|
+
|
|
106
|
+
## Multiple frontend domains
|
|
107
|
+
|
|
108
|
+
Each frontend gets its own `sitemaps/<domain>.cfg`. `add frontend` preserves other
|
|
109
|
+
domains and can be repeated safely. `set backend` updates every CLI-managed mapping
|
|
110
|
+
to the new personal API; it refuses to overwrite a manually edited mapping.
|
|
111
|
+
|
|
112
|
+
The first added frontend becomes `MIMIC_FRONT_DOMAIN` in `.env`. Further additions
|
|
113
|
+
preserve that local development choice. `set frontend <domain>` adds the domain
|
|
114
|
+
if needed and selects it for MIMIC; it does not remove other domains.
|
|
115
|
+
|
|
116
|
+
`remove frontend <domain>` removes that domain from the list and deletes only its
|
|
117
|
+
unchanged generated `.cfg`. Removing the MIMIC domain selects the first remaining
|
|
118
|
+
domain. Removing the last one clears MIMIC. Unknown or edited mappings cause a
|
|
119
|
+
clear error without changing files. Unrelated configurations remain untouched.
|
|
120
|
+
|
|
121
|
+
Restart development after domain changes to refresh Vite's allowed hosts and
|
|
122
|
+
Nuxt's environment. Production reloads mapping files on each API request; reload
|
|
123
|
+
the process environment when changing MIMIC. Configure DNS, TLS, and the hosting
|
|
124
|
+
proxy for each frontend separately.
|
|
125
|
+
|
|
126
|
+
Existing 0.1.0 setup state is migrated automatically, including other domain
|
|
127
|
+
mappings recorded in its generated-file manifest.
|
|
128
|
+
|
|
129
|
+
## Runtime behavior
|
|
130
|
+
|
|
131
|
+
The hosting proxy must overwrite `X-Front-Domain`, `X-Forwarded-Host`, and `Host`.
|
|
132
|
+
The gateway selects the first valid public domain in that order, with the request
|
|
133
|
+
URL hostname as another fallback. On localhost it uses `MIMIC_FRONT_DOMAIN`.
|
|
134
|
+
An unknown public domain returns `503`; it never uses another domain's mapping.
|
|
135
|
+
|
|
136
|
+
The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
|
|
137
|
+
end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
|
|
138
|
+
authorization and cookies are forwarded. Backend statuses are retained and cookie
|
|
139
|
+
Domain attributes are removed to bind cookies to the storefront. Gateway responses
|
|
140
|
+
use `Cache-Control: no-store`. Network failures return `502`.
|
|
141
|
+
|
|
142
|
+
Browser requests use `/api/...`. For SSR, use Nuxt's `useRequestFetch()` to pass
|
|
143
|
+
the current request's domain and authentication to the same gateway. See the
|
|
144
|
+
project README for examples and cookie handling.
|
|
145
|
+
|
|
146
|
+
The module sets private `runtimeConfig.mimicFrontDomain`, adds domain `Vary`
|
|
147
|
+
headers, extends Vite's allowed hosts from `.cfg` filenames, and defaults Nitro
|
|
148
|
+
to `node-server`. It does not set a fixed public site URL or generate canonical
|
|
149
|
+
links for your pages; use the resolved request domain for your own SEO metadata.
|
|
150
|
+
|
|
151
|
+
Deploy `sitemaps` with the production server, or set an absolute `SITEMAPS_DIR`.
|
|
152
|
+
The production process reads `MIMIC_FRONT_DOMAIN` without rebuilding. Run the
|
|
153
|
+
server from the project root or explicitly point it at the configuration directory.
|
|
154
|
+
|
|
155
|
+
## Documentation
|
|
156
|
+
|
|
157
|
+
- [Create a Nuxt frontend](https://orionmarket.place/sandbox/new-project/nuxt)
|
|
158
|
+
- [Connect your personal backend](https://orionmarket.place/sandbox/backend-domain/nuxt)
|
|
159
|
+
- [Marketplace API documentation](https://orionmarket.place/sandbox/)
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
npm install
|
|
165
|
+
npm test
|
|
166
|
+
npm pack --dry-run
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The published tarball includes only the CLI, module, runtime source, README,
|
|
170
|
+
license, and package metadata.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setupProject } from '../src/setup.mjs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
|
|
7
|
+
const help = `Orionplace Nuxt setup
|
|
8
|
+
|
|
9
|
+
npx orionplace-nuxt init [directory]
|
|
10
|
+
npx orionplace-nuxt set backend <personal-backend.orionplace.app>
|
|
11
|
+
npx orionplace-nuxt add frontend <shop.example.com>
|
|
12
|
+
npx orionplace-nuxt remove frontend <shop.example.com>
|
|
13
|
+
npx orionplace-nuxt set frontend <shop.example.com>
|
|
14
|
+
|
|
15
|
+
Options: --cwd <directory> --dry-run --help --version
|
|
16
|
+
|
|
17
|
+
Set backend or add frontend also initializes the integration; either can come first.
|
|
18
|
+
Add frontend keeps other domains and the current MIMIC domain. Set frontend selects MIMIC.
|
|
19
|
+
Set backend updates every managed frontend mapping. Remove frontend deletes only that mapping.
|
|
20
|
+
After creating a new project, run npm install and npm run dev in that directory.
|
|
21
|
+
Documentation and editable server source are generated in orionplace/.`;
|
|
22
|
+
try {
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
if (!argv.length || argv.includes('--help') || argv.includes('-h')) {
|
|
25
|
+
console.log(help);
|
|
26
|
+
} else if (argv.includes('--version')) {
|
|
27
|
+
console.log(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
28
|
+
} else {
|
|
29
|
+
const positional = [];
|
|
30
|
+
let cwd = process.cwd();
|
|
31
|
+
let dryRun = false;
|
|
32
|
+
for (let index = 0; index < argv.length; index++) {
|
|
33
|
+
if (argv[index] === '--dry-run') dryRun = true;
|
|
34
|
+
else if (argv[index] === '--cwd') {
|
|
35
|
+
if (!argv[index + 1]) throw new Error('--cwd needs a directory');
|
|
36
|
+
cwd = resolve(argv[++index]);
|
|
37
|
+
} else if (argv[index].startsWith('--')) throw new Error(`Unknown option: ${argv[index]}`);
|
|
38
|
+
else positional.push(argv[index]);
|
|
39
|
+
}
|
|
40
|
+
const [command, target] = positional;
|
|
41
|
+
let result;
|
|
42
|
+
if (command === 'init' && positional.length <= 2) {
|
|
43
|
+
result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
|
|
44
|
+
} else if (((command === 'set' && ['backend', 'frontend'].includes(target)) ||
|
|
45
|
+
(['add', 'remove'].includes(command) && target === 'frontend')) && positional.length <= 3) {
|
|
46
|
+
let value = positional[2];
|
|
47
|
+
if (!value && process.stdin.isTTY) {
|
|
48
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
49
|
+
try { value = await prompt.question(`${target === 'backend' ? 'Personal backend API' : 'Public frontend domain'}: `); }
|
|
50
|
+
finally { prompt.close(); }
|
|
51
|
+
}
|
|
52
|
+
if (!value) throw new Error(`Provide the ${target} domain. See --help.`);
|
|
53
|
+
result = await setupProject({ cwd, action: command, field: target, value, dryRun });
|
|
54
|
+
} else throw new Error('Unknown command. See --help.');
|
|
55
|
+
console.log(`${dryRun ? 'Planned' : 'Updated'} ${result.root}`);
|
|
56
|
+
for (const path of result.changed) console.log(` ${result.removed.includes(path) ? 'Remove: ' : ''}${path}`);
|
|
57
|
+
for (const path of result.preserved) console.log(` Preserved your edits: ${path}`);
|
|
58
|
+
console.log(result.settings.backend && result.settings.frontend
|
|
59
|
+
? `Ready: ${result.settings.frontends.join(', ')} -> ${result.settings.backend}\nLocal MIMIC: ${result.settings.frontend}`
|
|
60
|
+
: `Next: ${result.settings.backend ? 'add frontend' : 'set backend'} to complete the domain mapping.`);
|
|
61
|
+
if (result.changed.some(path => path.endsWith('.cfg') || path === '.env')) console.log('Restart a running development server to refresh allowed hosts and MIMIC. Reload your production environment when changing MIMIC.');
|
|
62
|
+
if (result.isNewNuxt) console.log('Run npm install in the project, then npm run dev.');
|
|
63
|
+
console.log('Project documentation: orionplace/README.md');
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`Orionplace: ${error.message}`);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orionplace-nuxt",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Nuxt integration and setup CLI for your Orionplace personal marketplace backend.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Orionplace",
|
|
8
|
+
"homepage": "https://orionmarket.place/sandbox/backend-domain/nuxt",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"nuxt",
|
|
11
|
+
"nuxt-module",
|
|
12
|
+
"orionplace",
|
|
13
|
+
"marketplace"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.12.0"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"orionplace-nuxt": "./bin/orionplace-nuxt.mjs"
|
|
20
|
+
},
|
|
21
|
+
"main": "./src/module.mjs",
|
|
22
|
+
"types": "./src/module.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./src/module.d.ts",
|
|
26
|
+
"import": "./src/module.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./runtime": "./runtime/marketplace.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"src",
|
|
33
|
+
"runtime",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test test/*.test.mjs"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"nuxt": "^4.0.0"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@nuxt/kit": "^4.5.0",
|
|
45
|
+
"h3": "^1.15.4",
|
|
46
|
+
"magicast": "^0.5.0"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public",
|
|
50
|
+
"registry": "https://registry.npmjs.org/"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/runtime/api.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// All browser and SSR /api requests use this gateway.
|
|
2
|
+
import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3';
|
|
3
|
+
import { useRuntimeConfig } from '#imports';
|
|
4
|
+
import { forwardMarketplace } from './marketplace.mjs';
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
const response = await forwardMarketplace(toWebRequest(event), {
|
|
8
|
+
mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? useRuntimeConfig(event).mimicFrontDomain,
|
|
9
|
+
});
|
|
10
|
+
return sendWebResponse(event, response);
|
|
11
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Other server code can read event.context.marketplaceDomain for canonical URLs.
|
|
2
|
+
import { defineEventHandler, toWebRequest } from 'h3';
|
|
3
|
+
import { useRuntimeConfig } from '#imports';
|
|
4
|
+
import { resolveFrontDomain } from './marketplace.mjs';
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler((event) => {
|
|
7
|
+
event.context.marketplaceDomain = resolveFrontDomain(toWebRequest(event),
|
|
8
|
+
process.env.MIMIC_FRONT_DOMAIN ?? useRuntimeConfig(event).mimicFrontDomain);
|
|
9
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
|
|
5
|
+
const environment = process.env;
|
|
6
|
+
|
|
7
|
+
const hostnamePattern =
|
|
8
|
+
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
9
|
+
|
|
10
|
+
/** @param {unknown} value */
|
|
11
|
+
export function normalizeFrontDomain(value) {
|
|
12
|
+
if (typeof value !== 'string') return null;
|
|
13
|
+
const domain = value.split(',')[0].trim().toLowerCase().replace(/\.$/, '');
|
|
14
|
+
return hostnamePattern.test(domain) && !isIP(domain) ? domain : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {Request} request @param {string} mimic */
|
|
18
|
+
export function resolveFrontDomain(
|
|
19
|
+
request,
|
|
20
|
+
mimic = environment.MIMIC_FRONT_DOMAIN || '',
|
|
21
|
+
) {
|
|
22
|
+
// The hosting proxy must overwrite these headers. Public hosts win over MIMIC.
|
|
23
|
+
for (const candidate of [
|
|
24
|
+
request.headers.get('x-front-domain'),
|
|
25
|
+
request.headers.get('x-forwarded-host'),
|
|
26
|
+
request.headers.get('host'),
|
|
27
|
+
new URL(request.url).hostname,
|
|
28
|
+
]) {
|
|
29
|
+
const domain = normalizeFrontDomain(candidate);
|
|
30
|
+
if (domain) return domain;
|
|
31
|
+
}
|
|
32
|
+
return normalizeFrontDomain(mimic);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
|
|
36
|
+
export async function readMarketplace(request, configuration = {}) {
|
|
37
|
+
const domain = resolveFrontDomain(request, configuration.mimicFrontDomain);
|
|
38
|
+
if (!domain) throw new Error('Invalid storefront domain');
|
|
39
|
+
|
|
40
|
+
const directory = environment.SITEMAPS_DIR || resolve('sitemaps');
|
|
41
|
+
const source = await readFile(resolve(directory, `${domain}.cfg`), 'utf8');
|
|
42
|
+
const lines = source
|
|
43
|
+
.split(/\r?\n/)
|
|
44
|
+
.map((line) => line.trim())
|
|
45
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
46
|
+
if (lines.length !== 1) throw new Error('Expected one API URL');
|
|
47
|
+
const value = lines[0];
|
|
48
|
+
const api = new URL(value.includes('://') ? value : `https://${value}`);
|
|
49
|
+
api.pathname = api.pathname.replace(/\/+$/, '');
|
|
50
|
+
if (
|
|
51
|
+
api.protocol !== 'https:' ||
|
|
52
|
+
!hostnamePattern.test(api.hostname) ||
|
|
53
|
+
!api.hostname.endsWith('.orionplace.app') ||
|
|
54
|
+
isIP(api.hostname) ||
|
|
55
|
+
api.port ||
|
|
56
|
+
api.username ||
|
|
57
|
+
api.password ||
|
|
58
|
+
api.search ||
|
|
59
|
+
api.hash ||
|
|
60
|
+
api.pathname !== '/api'
|
|
61
|
+
) {
|
|
62
|
+
throw new Error('Expected https://<project>.orionplace.app/api');
|
|
63
|
+
}
|
|
64
|
+
return { domain, api };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {number} status @param {string} message */
|
|
68
|
+
function failure(status, message) {
|
|
69
|
+
return Response.json(
|
|
70
|
+
{ error: message },
|
|
71
|
+
{
|
|
72
|
+
status,
|
|
73
|
+
headers: {
|
|
74
|
+
'Cache-Control': 'no-store',
|
|
75
|
+
'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
|
|
82
|
+
export async function forwardMarketplace(request, configuration = {}) {
|
|
83
|
+
let tenant;
|
|
84
|
+
try {
|
|
85
|
+
tenant = await readMarketplace(request, configuration);
|
|
86
|
+
} catch {
|
|
87
|
+
return failure(
|
|
88
|
+
503,
|
|
89
|
+
'Storefront domain configuration is missing or invalid',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const incoming = new URL(request.url);
|
|
94
|
+
if (!incoming.pathname.startsWith('/api/'))
|
|
95
|
+
return failure(404, 'Unknown API path');
|
|
96
|
+
// Prevent encoded separators or traversal from escaping the /api namespace.
|
|
97
|
+
let path;
|
|
98
|
+
try {
|
|
99
|
+
path = decodeURIComponent(incoming.pathname);
|
|
100
|
+
} catch {
|
|
101
|
+
return failure(400, 'Invalid API path');
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
path.includes('\\') ||
|
|
105
|
+
/%(?:2e|2f|5c)/i.test(path) ||
|
|
106
|
+
path.split('/').some((part) => part === '.' || part === '..')
|
|
107
|
+
) {
|
|
108
|
+
return failure(400, 'Invalid API path');
|
|
109
|
+
}
|
|
110
|
+
const target = new URL(
|
|
111
|
+
incoming.pathname + incoming.search,
|
|
112
|
+
tenant.api.origin,
|
|
113
|
+
);
|
|
114
|
+
if (!target.pathname.startsWith('/api/'))
|
|
115
|
+
return failure(400, 'Invalid API path');
|
|
116
|
+
|
|
117
|
+
const origin = request.headers.get('origin');
|
|
118
|
+
const publicHost = request.headers.get('host') || incoming.host;
|
|
119
|
+
const originHosts = [publicHost, tenant.domain];
|
|
120
|
+
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
|
|
121
|
+
try {
|
|
122
|
+
if (
|
|
123
|
+
request.headers.get('sec-fetch-site') === 'cross-site' ||
|
|
124
|
+
(origin && !originHosts.includes(new URL(origin).host))
|
|
125
|
+
) {
|
|
126
|
+
return failure(403, 'Cross-site API request rejected');
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
return failure(403, 'Invalid Origin');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// An allowlist excludes hop-by-hop and client-supplied routing headers.
|
|
134
|
+
const headers = new Headers();
|
|
135
|
+
for (const name of [
|
|
136
|
+
'accept',
|
|
137
|
+
'content-type',
|
|
138
|
+
'authorization',
|
|
139
|
+
'cookie',
|
|
140
|
+
'origin',
|
|
141
|
+
'referer',
|
|
142
|
+
'x-csrftoken',
|
|
143
|
+
'accept-language',
|
|
144
|
+
'user-agent',
|
|
145
|
+
'x-tz',
|
|
146
|
+
]) {
|
|
147
|
+
const value = request.headers.get(name);
|
|
148
|
+
if (value) headers.set(name, value);
|
|
149
|
+
}
|
|
150
|
+
headers.set('X-Front-Domain', tenant.domain);
|
|
151
|
+
headers.set('Accept-Encoding', 'identity');
|
|
152
|
+
const clientIp =
|
|
153
|
+
request.headers.get('x-init-ip') ||
|
|
154
|
+
request.headers.get('x-real-ip') ||
|
|
155
|
+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
156
|
+
if (clientIp) headers.set('X-Init-Ip', clientIp);
|
|
157
|
+
/** @type {RequestInit & { duplex: 'half' }} */
|
|
158
|
+
const options = {
|
|
159
|
+
method: request.method,
|
|
160
|
+
headers,
|
|
161
|
+
redirect: 'manual',
|
|
162
|
+
cache: 'no-store',
|
|
163
|
+
signal: AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]),
|
|
164
|
+
duplex: 'half',
|
|
165
|
+
};
|
|
166
|
+
if (!['GET', 'HEAD'].includes(request.method)) options.body = request.body;
|
|
167
|
+
|
|
168
|
+
let upstream;
|
|
169
|
+
try {
|
|
170
|
+
upstream = await fetch(target, options);
|
|
171
|
+
} catch {
|
|
172
|
+
return failure(502, 'Marketplace backend is unavailable');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const responseHeaders = new Headers();
|
|
176
|
+
for (const name of [
|
|
177
|
+
'content-type',
|
|
178
|
+
'content-disposition',
|
|
179
|
+
'www-authenticate',
|
|
180
|
+
'retry-after',
|
|
181
|
+
'allow',
|
|
182
|
+
]) {
|
|
183
|
+
const value = upstream.headers.get(name);
|
|
184
|
+
if (value) responseHeaders.set(name, value);
|
|
185
|
+
}
|
|
186
|
+
// Keep each cookie separate; remove the backend Domain attribute only.
|
|
187
|
+
for (const cookie of upstream.headers.getSetCookie()) {
|
|
188
|
+
responseHeaders.append(
|
|
189
|
+
'Set-Cookie',
|
|
190
|
+
cookie.replace(/;\s*Domain=[^;]*/gi, ''),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const location = upstream.headers.get('location');
|
|
194
|
+
if (location) {
|
|
195
|
+
const destination = new URL(location, target);
|
|
196
|
+
responseHeaders.set(
|
|
197
|
+
'Location',
|
|
198
|
+
destination.origin === tenant.api.origin
|
|
199
|
+
? destination.pathname + destination.search + destination.hash
|
|
200
|
+
: destination.href,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
responseHeaders.set('Cache-Control', 'no-store');
|
|
204
|
+
responseHeaders.set('Vary', 'Host, X-Forwarded-Host, X-Front-Domain');
|
|
205
|
+
// fetch decodes compressed bodies: do not reuse upstream Content-Length/Encoding.
|
|
206
|
+
return new Response(upstream.body, {
|
|
207
|
+
status: upstream.status,
|
|
208
|
+
statusText: upstream.statusText,
|
|
209
|
+
headers: responseHeaders,
|
|
210
|
+
});
|
|
211
|
+
}
|
package/src/module.d.ts
ADDED
package/src/module.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineNuxtModule, addServerHandler } from '@nuxt/kit';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { normalizeFrontDomain } from '../runtime/marketplace.mjs';
|
|
5
|
+
|
|
6
|
+
export default defineNuxtModule({
|
|
7
|
+
meta: { name: 'orionplace-nuxt', configKey: 'orionplace', compatibility: { nuxt: '^4.0.0' } },
|
|
8
|
+
setup(_options, nuxt) {
|
|
9
|
+
const directory = resolve(nuxt.options.rootDir, 'orionplace/runtime');
|
|
10
|
+
// Generated sources live outside server/, so inline them in development too.
|
|
11
|
+
nuxt.options.nitro.externals ||= {};
|
|
12
|
+
nuxt.options.nitro.externals.inline ||= [];
|
|
13
|
+
nuxt.options.nitro.externals.inline.push(directory);
|
|
14
|
+
if (!existsSync(resolve(directory, 'api.mjs'))) {
|
|
15
|
+
throw new Error('Run npx orionplace-nuxt init to generate the Orionplace integration.');
|
|
16
|
+
}
|
|
17
|
+
if (nuxt.options.nitro.preset && nuxt.options.nitro.preset !== 'node-server') {
|
|
18
|
+
throw new Error('Orionplace domain files require the Nitro node-server preset.');
|
|
19
|
+
}
|
|
20
|
+
const routes = nuxt.options.routeRules;
|
|
21
|
+
for (const path of ['/api', '/api/**', '/api/*']) {
|
|
22
|
+
if (routes[path]?.proxy || routes[path]?.redirect) {
|
|
23
|
+
throw new Error(`Remove the conflicting ${path} proxy/redirect: Orionplace handles API routing.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (nuxt.options.vite.server?.proxy?.['/api']) {
|
|
27
|
+
throw new Error('Remove the Vite /api proxy: Orionplace provides a server gateway in development too.');
|
|
28
|
+
}
|
|
29
|
+
// Do not inherit page caching for authenticated API requests.
|
|
30
|
+
routes['/api/**'] = { ...routes['/api/**'], cache: false, swr: false,
|
|
31
|
+
headers: { ...routes['/api/**']?.headers, 'cache-control': 'no-store' } };
|
|
32
|
+
// Values stay private to the server. MIMIC_FRONT_DOMAIN is also read at runtime.
|
|
33
|
+
nuxt.options.runtimeConfig.mimicFrontDomain ??= '';
|
|
34
|
+
nuxt.options.runtimeConfig.mimicFrontDomain = process.env.MIMIC_FRONT_DOMAIN
|
|
35
|
+
?? nuxt.options.runtimeConfig.mimicFrontDomain;
|
|
36
|
+
nuxt.options.nitro.preset ||= 'node-server';
|
|
37
|
+
const vary = new Set(String(routes['/**']?.headers?.vary || '').split(',').map(x => x.trim()).filter(Boolean));
|
|
38
|
+
for (const header of ['Host', 'X-Forwarded-Host', 'X-Front-Domain']) vary.add(header);
|
|
39
|
+
routes['/**'] = { ...routes['/**'], headers: { ...routes['/**']?.headers, vary: [...vary].join(', ') } };
|
|
40
|
+
const configs = resolve(nuxt.options.rootDir, process.env.SITEMAPS_DIR || 'sitemaps');
|
|
41
|
+
const hosts = existsSync(configs) ? readdirSync(configs).filter(x => x.endsWith('.cfg')).map(x => normalizeFrontDomain(x.slice(0, -4))).filter(Boolean) : [];
|
|
42
|
+
nuxt.options.vite.server ||= {};
|
|
43
|
+
if (nuxt.options.vite.server.allowedHosts !== true) {
|
|
44
|
+
nuxt.options.vite.server.allowedHosts = [...new Set([...(nuxt.options.vite.server.allowedHosts || []), ...hosts])];
|
|
45
|
+
}
|
|
46
|
+
addServerHandler({ route: '/api/**', handler: resolve(directory, 'api.mjs') });
|
|
47
|
+
addServerHandler({ middleware: true, handler: resolve(directory, 'domain.mjs') });
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
export function projectReadme(settings) {
|
|
2
|
+
const frontend = settings.frontend || 'shop.example.com';
|
|
3
|
+
const backend = settings.backend || 'https://your-project.orionplace.app/api';
|
|
4
|
+
return `# Your Orionplace Nuxt integration
|
|
5
|
+
|
|
6
|
+
This folder contains the integration source for your Nuxt 4 storefront.
|
|
7
|
+
Your personal backend API is provided in your account at https://orionmarket.place.
|
|
8
|
+
You can change that backend domain in your account, then update this project.
|
|
9
|
+
|
|
10
|
+
## Current setup
|
|
11
|
+
|
|
12
|
+
- Frontend domains: ${settings.frontends?.length ? settings.frontends.map(domain => '\`' + domain + '\`').join(', ') : 'not configured yet'}
|
|
13
|
+
- Local MIMIC domain: \`${settings.frontend || 'not configured yet'}\`
|
|
14
|
+
- Personal API: \`${settings.backend || 'not configured yet'}\`
|
|
15
|
+
|
|
16
|
+
Set or change these values from the project root:
|
|
17
|
+
|
|
18
|
+
\`\`\`bash
|
|
19
|
+
npx orionplace-nuxt set backend ${backend}
|
|
20
|
+
npx orionplace-nuxt add frontend ${frontend}
|
|
21
|
+
npx orionplace-nuxt add frontend www.${frontend}
|
|
22
|
+
npx orionplace-nuxt remove frontend www.${frontend}
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
Set the backend or add a frontend first; either order works. Each frontend has its
|
|
26
|
+
own \`sitemaps/<domain>.cfg\` mapping to your personal API. Adding a domain again
|
|
27
|
+
is safe. Changing the backend updates every CLI-managed frontend mapping.
|
|
28
|
+
|
|
29
|
+
The first added frontend becomes \`MIMIC_FRONT_DOMAIN\` in \`.env\`. Adding more
|
|
30
|
+
domains preserves that choice. Use \`npx orionplace-nuxt set frontend <domain>\`
|
|
31
|
+
to add and select a different local domain.
|
|
32
|
+
|
|
33
|
+
Removing a frontend deletes only its unchanged generated \`.cfg\` file. If it was
|
|
34
|
+
the selected MIMIC domain, the first remaining domain takes its place. Removing
|
|
35
|
+
the last frontend clears MIMIC; add a frontend again before making API requests.
|
|
36
|
+
Unknown or manually edited mappings are not removed automatically.
|
|
37
|
+
|
|
38
|
+
Restart a running development server after changing domains so Vite reloads its
|
|
39
|
+
allowed hosts and Nuxt reloads \`.env\`. Production reads domain files on each
|
|
40
|
+
request; restart or reload the process environment when changing MIMIC. DNS, TLS
|
|
41
|
+
and hosting-proxy settings must also cover each public frontend domain.
|
|
42
|
+
Existing unrelated environment variables and Nuxt configuration are preserved.
|
|
43
|
+
|
|
44
|
+
## Files you can read and customize
|
|
45
|
+
|
|
46
|
+
| File | Purpose |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| \`runtime/marketplace.mjs\` | Domain lookup, configuration validation, API transport and cookies. |
|
|
49
|
+
| \`runtime/api.mjs\` | The Nuxt/Nitro HTTP handler for \`/api/**\`. |
|
|
50
|
+
| \`runtime/domain.mjs\` | Resolves the frontend domain into the request context. |
|
|
51
|
+
| \`settings.json\` | The CLI's frontend list, local MIMIC domain and personal backend; not a secret store. |
|
|
52
|
+
| \`../sitemaps/${frontend}.cfg\` | The backend URL used for this frontend domain. |
|
|
53
|
+
| \`../.env\` | Local MIMIC setting; keep secrets out of version control. |
|
|
54
|
+
| \`../nuxt.config.ts\` | Registers the \`orionplace-nuxt\` module. |
|
|
55
|
+
|
|
56
|
+
The module registers these local source files with Nitro. You can edit them;
|
|
57
|
+
subsequent setup runs preserve customized runtime files and this README.
|
|
58
|
+
The CLI refuses to overwrite an unknown or manually changed domain mapping.
|
|
59
|
+
|
|
60
|
+
## How requests reach your personal API
|
|
61
|
+
|
|
62
|
+
The trusted hosting proxy supplies the public domain through \`X-Front-Domain\`,
|
|
63
|
+
\`X-Forwarded-Host\`, or \`Host\`, in that order. On localhost,
|
|
64
|
+
\`MIMIC_FRONT_DOMAIN\` supplies the domain instead. A known public domain takes
|
|
65
|
+
priority over MIMIC; an unconfigured public domain returns \`503\`.
|
|
66
|
+
|
|
67
|
+
The domain file is read for each request. The proxy preserves the request method,
|
|
68
|
+
query, body, authorization and cookies. Backend HTTP status codes are preserved.
|
|
69
|
+
Backend cookie domains are removed so cookies belong to the active frontend.
|
|
70
|
+
Responses are not cached by this gateway. Network failures return \`502\`.
|
|
71
|
+
|
|
72
|
+
The configuration directory defaults to \`sitemaps\` under the process working
|
|
73
|
+
directory. Set \`SITEMAPS_DIR\` only if you store it elsewhere. Runtime configuration
|
|
74
|
+
does not set a fixed public site URL. The middleware exposes the active domain
|
|
75
|
+
as \`event.context.marketplaceDomain\`; use it when implementing your page metadata.
|
|
76
|
+
|
|
77
|
+
## Start and verify
|
|
78
|
+
|
|
79
|
+
Use Node.js 22.12 or newer. After scaffolding a new project, install dependencies:
|
|
80
|
+
|
|
81
|
+
\`\`\`bash
|
|
82
|
+
npm install
|
|
83
|
+
npm run dev
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
In another terminal:
|
|
87
|
+
|
|
88
|
+
\`\`\`bash
|
|
89
|
+
curl -i http://localhost:3000/api/appload \\
|
|
90
|
+
-H 'Content-Type: application/json' \\
|
|
91
|
+
--data '{"ignore_cache":true,"detalize_mode":"full"}'
|
|
92
|
+
\`\`\`
|
|
93
|
+
|
|
94
|
+
Check both the HTTP status and \`form_errors\`. Successful initialization returns
|
|
95
|
+
\`page_data\`; HTTP 200 by itself is insufficient. Documentation and public requests
|
|
96
|
+
do not require a login prompt. Protected actions still require backend permission.
|
|
97
|
+
|
|
98
|
+
Browser requests use the storefront's own API path:
|
|
99
|
+
|
|
100
|
+
\`\`\`javascript
|
|
101
|
+
const response = await fetch('/api/appload', {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
credentials: 'same-origin',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ ignore_cache: true, detalize_mode: 'full' }),
|
|
106
|
+
});
|
|
107
|
+
const result = await response.json();
|
|
108
|
+
console.log(response.status, result);
|
|
109
|
+
\`\`\`
|
|
110
|
+
|
|
111
|
+
For SSR, use Nuxt's \`useRequestFetch()\` so the request's domain, cookie and
|
|
112
|
+
authorization headers reach the same gateway. For example, in \`app/app.vue\`:
|
|
113
|
+
|
|
114
|
+
\`\`\`vue
|
|
115
|
+
<script setup>
|
|
116
|
+
const requestFetch = useRequestFetch();
|
|
117
|
+
const { data, error } = await useAsyncData('marketplace-initialization', () =>
|
|
118
|
+
requestFetch('/api/appload', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
body: { ignore_cache: true, detalize_mode: 'full' },
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
</script>
|
|
124
|
+
|
|
125
|
+
<template>
|
|
126
|
+
<main>
|
|
127
|
+
<p v-if="error">The marketplace API is currently unavailable.</p>
|
|
128
|
+
<pre v-else>{{ data }}</pre>
|
|
129
|
+
</main>
|
|
130
|
+
</template>
|
|
131
|
+
\`\`\`
|
|
132
|
+
|
|
133
|
+
Keep private data out of shared caches. This SSR example forwards incoming cookies;
|
|
134
|
+
it does not relay new upstream cookies to the browser. Make session-changing calls
|
|
135
|
+
from the browser, or explicitly append each returned Set-Cookie header to the SSR
|
|
136
|
+
response in your server integration.
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
## Deploy on your VPS
|
|
140
|
+
|
|
141
|
+
This integration needs a Node.js server and its domain configuration files.
|
|
142
|
+
It does not support static-only or edge hosting. Deploy the build, dependencies,
|
|
143
|
+
\`sitemaps\`, and your environment configuration together; use an absolute
|
|
144
|
+
\`SITEMAPS_DIR\` when running from another working directory.
|
|
145
|
+
|
|
146
|
+
\`\`\`bash
|
|
147
|
+
npm run build
|
|
148
|
+
HOST=127.0.0.1 PORT=3000 node --env-file=.env .output/server/index.mjs
|
|
149
|
+
\`\`\`
|
|
150
|
+
|
|
151
|
+
The production process explicitly reads \`MIMIC_FRONT_DOMAIN\`. A public domain
|
|
152
|
+
still takes precedence. The Node port must stay private behind your HTTPS proxy.
|
|
153
|
+
|
|
154
|
+
Inside the storefront's HTTPS Nginx server block:
|
|
155
|
+
|
|
156
|
+
\`\`\`nginx
|
|
157
|
+
location / {
|
|
158
|
+
proxy_pass http://127.0.0.1:3000;
|
|
159
|
+
proxy_http_version 1.1;
|
|
160
|
+
proxy_set_header Host $host;
|
|
161
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
162
|
+
proxy_set_header X-Front-Domain $host;
|
|
163
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
164
|
+
proxy_set_header X-Init-Ip $remote_addr;
|
|
165
|
+
proxy_buffering off;
|
|
166
|
+
}
|
|
167
|
+
\`\`\`
|
|
168
|
+
|
|
169
|
+
Here localhost is your frontend process on your VPS. That process selects the
|
|
170
|
+
remote personal API from the domain file. The proxy must overwrite those headers.
|
|
171
|
+
An unknown domain never falls back to another tenant. This setup does not generate
|
|
172
|
+
sitemap XML or add analytics.
|
|
173
|
+
|
|
174
|
+
## More documentation
|
|
175
|
+
|
|
176
|
+
- [Nuxt project setup](https://orionmarket.place/sandbox/new-project/nuxt)
|
|
177
|
+
- [Domain setup and direct Nginx alternative](https://orionmarket.place/sandbox/backend-domain/nuxt)
|
|
178
|
+
- [AppLoad](https://orionmarket.place/sandbox)
|
|
179
|
+
`;
|
|
180
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFile, readdir, lstat, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname, relative, sep } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { parseModule, generateCode } from 'magicast';
|
|
5
|
+
import { backendUrl, frontendDomain } from './validation.mjs';
|
|
6
|
+
import { projectReadme } from './project-readme.mjs';
|
|
7
|
+
|
|
8
|
+
const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
10
|
+
async function read(path) {
|
|
11
|
+
try { return await readFile(path, 'utf8'); }
|
|
12
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
13
|
+
}
|
|
14
|
+
async function safePath(root, name) {
|
|
15
|
+
const target = resolve(root, name);
|
|
16
|
+
if (relative(root, target).startsWith('..') || target === root) throw new Error('Invalid generated path');
|
|
17
|
+
let cursor = root;
|
|
18
|
+
for (const piece of relative(root, target).split(sep)) {
|
|
19
|
+
cursor = resolve(cursor, piece);
|
|
20
|
+
try { if ((await lstat(cursor)).isSymbolicLink()) throw new Error(`Refusing symbolic link: ${name}`); }
|
|
21
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
22
|
+
}
|
|
23
|
+
return target;
|
|
24
|
+
}
|
|
25
|
+
function configureNuxt(source) {
|
|
26
|
+
try {
|
|
27
|
+
const module = parseModule(source);
|
|
28
|
+
const options = module.exports.default.$type === 'function-call'
|
|
29
|
+
? module.exports.default.$args[0] : module.exports.default;
|
|
30
|
+
if (!options || options.$type !== 'object') throw new Error('Dynamic configuration');
|
|
31
|
+
if (options.nitro?.preset && options.nitro.preset !== 'node-server') throw new Error('Unsupported Nitro preset');
|
|
32
|
+
if (options.srcDir || options.serverDir || options.extends) throw new Error('Custom directories or layers');
|
|
33
|
+
for (const route of ['/api', '/api/**', '/api/*']) {
|
|
34
|
+
if (options.routeRules?.[route]?.proxy || options.routeRules?.[route]?.redirect) throw new Error('Conflicting API routing');
|
|
35
|
+
}
|
|
36
|
+
if (options.vite?.server?.proxy?.['/api']) throw new Error('Conflicting Vite proxy');
|
|
37
|
+
options.modules ||= [];
|
|
38
|
+
if (!Array.from(options.modules).some(item => item === 'orionplace-nuxt' || (Array.isArray(item) && item[0] === 'orionplace-nuxt'))) {
|
|
39
|
+
options.modules.push('orionplace-nuxt');
|
|
40
|
+
}
|
|
41
|
+
return generateCode(module).code + '\n';
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error('Cannot safely update this Nuxt configuration. Use a standard Nuxt 4 root config with a literal modules array and no custom srcDir/serverDir, layers, non-Node presets, or /api proxy. No files were changed.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function updateEnvironment(source, domain) {
|
|
47
|
+
const lines = (source || '').split(/\r?\n/);
|
|
48
|
+
let updated = false;
|
|
49
|
+
const result = lines.flatMap(line => {
|
|
50
|
+
if (!/^\s*(?:export\s+)?MIMIC_FRONT_DOMAIN\s*=/.test(line)) return [line];
|
|
51
|
+
if (updated) return [];
|
|
52
|
+
updated = true;
|
|
53
|
+
return [`MIMIC_FRONT_DOMAIN="${domain}"`];
|
|
54
|
+
});
|
|
55
|
+
if (!updated) result.push(`MIMIC_FRONT_DOMAIN="${domain}"`);
|
|
56
|
+
return result.join('\n').replace(/^\n/, '').replace(/\n*$/, '\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function setupProject({ cwd = process.cwd(), action = 'set', field, value, dryRun = false } = {}) {
|
|
60
|
+
if (!['set', 'add', 'remove'].includes(action) || (action !== 'set' && field !== 'frontend')) {
|
|
61
|
+
throw new Error('Use set backend, add frontend, remove frontend, or set frontend.');
|
|
62
|
+
}
|
|
63
|
+
const root = resolve(cwd);
|
|
64
|
+
const setting = field === 'backend' ? backendUrl(value) : field === 'frontend' ? frontendDomain(value) : null;
|
|
65
|
+
if (field && !setting) throw new Error('Choose backend or frontend.');
|
|
66
|
+
const names = ['package.json', 'nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs', 'orionplace/settings.json', 'orionplace/.generated.json', '.env', '.gitignore'];
|
|
67
|
+
const current = {};
|
|
68
|
+
for (const name of names) current[name] = await read(await safePath(root, name));
|
|
69
|
+
const pkg = current['package.json'] ? JSON.parse(current['package.json']) : { name: 'orionplace-storefront', private: true, type: 'module' };
|
|
70
|
+
const dependencies = { ...pkg.devDependencies, ...pkg.dependencies };
|
|
71
|
+
if (!dependencies.nuxt && ['next', '@sveltejs/kit'].some(name => dependencies[name])) throw new Error('This package sets up Nuxt projects only.');
|
|
72
|
+
if (dependencies.nuxt && !/^(?:[~^]|>=)?4(?:\.|$)/.test(dependencies.nuxt)) {
|
|
73
|
+
throw new Error('This release supports Nuxt 4. Upgrade your Nuxt dependency before setup.');
|
|
74
|
+
}
|
|
75
|
+
const configs = ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs'].filter(name => current[name] !== null);
|
|
76
|
+
if (configs.length > 1) throw new Error('Multiple Nuxt configuration files found; keep one root config.');
|
|
77
|
+
const configName = configs[0] || 'nuxt.config.ts';
|
|
78
|
+
const config = configureNuxt(current[configName] || 'export default defineNuxtConfig({ modules: [] });\n');
|
|
79
|
+
for (const directory of ['server/api', 'server/routes/api']) {
|
|
80
|
+
const path = await safePath(root, directory);
|
|
81
|
+
let entries = [];
|
|
82
|
+
try { entries = await readdir(path); }
|
|
83
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
84
|
+
if (entries.some(name => name.startsWith('[...'))) {
|
|
85
|
+
throw new Error(`Existing API catch-all in ${directory}. Resolve this route conflict first.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const settings = current['orionplace/settings.json'] ? JSON.parse(current['orionplace/settings.json']) : {};
|
|
89
|
+
const previous = current['orionplace/.generated.json'] ? JSON.parse(current['orionplace/.generated.json']) : {};
|
|
90
|
+
if (settings.backend) settings.backend = backendUrl(settings.backend);
|
|
91
|
+
if (settings.frontend) settings.frontend = frontendDomain(settings.frontend);
|
|
92
|
+
const oldFrontend = settings.frontend;
|
|
93
|
+
// Migrate the initial single-domain format, including its previously generated mappings.
|
|
94
|
+
const legacyDomains = Object.keys(previous)
|
|
95
|
+
.map(name => /^sitemaps\/([^/]+)\.cfg$/.exec(name)?.[1]).filter(Boolean);
|
|
96
|
+
const frontends = settings.frontends ?? [settings.frontend, ...legacyDomains].filter(Boolean);
|
|
97
|
+
if (!Array.isArray(frontends)) throw new Error('settings.frontends must be an array of domain names.');
|
|
98
|
+
settings.frontends = [...new Set(frontends.map(frontendDomain))];
|
|
99
|
+
if (settings.frontend && !settings.frontends.includes(settings.frontend)) settings.frontends.unshift(settings.frontend);
|
|
100
|
+
if (field === 'backend') settings.backend = setting;
|
|
101
|
+
if (field === 'frontend') {
|
|
102
|
+
if (action === 'remove') {
|
|
103
|
+
if (!settings.frontends.includes(setting)) throw new Error(`Frontend is not managed by this project: ${setting}`);
|
|
104
|
+
settings.frontends = settings.frontends.filter(domain => domain !== setting);
|
|
105
|
+
if (settings.frontend === setting) settings.frontend = settings.frontends[0];
|
|
106
|
+
} else {
|
|
107
|
+
if (!settings.frontends.includes(setting)) settings.frontends.push(setting);
|
|
108
|
+
// Adding an alias preserves the local development domain. "set" selects it.
|
|
109
|
+
if (action === 'set' || !settings.frontend) settings.frontend = setting;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
settings.frontend ||= settings.frontends[0];
|
|
113
|
+
if (!settings.frontend) delete settings.frontend;
|
|
114
|
+
const manifest = { ...previous };
|
|
115
|
+
const changes = new Map();
|
|
116
|
+
const preserved = [];
|
|
117
|
+
async function plan(name, content, { managed = false, preserve = false } = {}) {
|
|
118
|
+
const path = await safePath(root, name);
|
|
119
|
+
const existing = await read(path);
|
|
120
|
+
if (managed && existing !== null && existing !== content && previous[name] !== hash(existing)) {
|
|
121
|
+
if (preserve && previous[name]) { preserved.push(name); return; }
|
|
122
|
+
throw new Error(`Refusing to overwrite ${name}: it is not an unchanged generated file. No files were changed.`);
|
|
123
|
+
}
|
|
124
|
+
if (existing !== content) changes.set(name, content);
|
|
125
|
+
if (managed) manifest[name] = hash(content);
|
|
126
|
+
}
|
|
127
|
+
async function planRemoval(name) {
|
|
128
|
+
const existing = await read(await safePath(root, name));
|
|
129
|
+
if (existing !== null && previous[name] !== hash(existing)) {
|
|
130
|
+
throw new Error(`Refusing to remove ${name}: it is not an unchanged generated file. No files were changed.`);
|
|
131
|
+
}
|
|
132
|
+
if (existing !== null) changes.set(name, null);
|
|
133
|
+
delete manifest[name];
|
|
134
|
+
}
|
|
135
|
+
// All checks and content generation happen before the first write.
|
|
136
|
+
await plan(configName, config);
|
|
137
|
+
const isNewNuxt = !dependencies.nuxt;
|
|
138
|
+
pkg.scripts ||= {};
|
|
139
|
+
pkg.scripts.dev ||= 'nuxt dev';
|
|
140
|
+
pkg.scripts.build ||= 'nuxt build';
|
|
141
|
+
pkg.scripts.preview ||= 'nuxt preview';
|
|
142
|
+
pkg.scripts['orionplace:backend'] ||= 'orionplace-nuxt set backend';
|
|
143
|
+
pkg.scripts['orionplace:frontend'] ||= 'orionplace-nuxt set frontend';
|
|
144
|
+
pkg.scripts['orionplace:add-frontend'] ||= 'orionplace-nuxt add frontend';
|
|
145
|
+
pkg.scripts['orionplace:remove-frontend'] ||= 'orionplace-nuxt remove frontend';
|
|
146
|
+
pkg.dependencies ||= {};
|
|
147
|
+
pkg.dependencies['orionplace-nuxt'] ||= dependencies['orionplace-nuxt'] || `^${metadata.version}`;
|
|
148
|
+
if (isNewNuxt) {
|
|
149
|
+
pkg.dependencies.nuxt = '^4.5.0';
|
|
150
|
+
pkg.dependencies.vue ||= '^3.5.0';
|
|
151
|
+
pkg.dependencies['vue-router'] ||= '^5.0.0';
|
|
152
|
+
}
|
|
153
|
+
await plan('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
154
|
+
for (const file of ['marketplace.mjs', 'api.mjs', 'domain.mjs']) {
|
|
155
|
+
await plan(`orionplace/runtime/${file}`, await readFile(new URL(`../runtime/${file}`, import.meta.url), 'utf8'), { managed: true, preserve: true });
|
|
156
|
+
}
|
|
157
|
+
await plan('orionplace/README.md', projectReadme(settings), { managed: true, preserve: true });
|
|
158
|
+
if (isNewNuxt && !(await read(await safePath(root, 'app/app.vue')))) {
|
|
159
|
+
await plan('app/app.vue', '<template>\n <main>\n <h1>Your marketplace</h1>\n <p>Domain setup and API instructions are in orionplace/README.md.</p>\n </main>\n</template>\n', { managed: true });
|
|
160
|
+
}
|
|
161
|
+
if (settings.frontend || oldFrontend) await plan('.env', updateEnvironment(current['.env'], settings.frontend || ''));
|
|
162
|
+
if (settings.backend) {
|
|
163
|
+
const mappings = action === 'remove' ? []
|
|
164
|
+
: field === 'frontend' ? [setting] : settings.frontends;
|
|
165
|
+
for (const domain of mappings) {
|
|
166
|
+
await plan(`sitemaps/${domain}.cfg`, settings.backend + '\n', { managed: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (action === 'remove') await planRemoval(`sitemaps/${setting}.cfg`);
|
|
170
|
+
let ignore = current['.gitignore'] || '';
|
|
171
|
+
for (const entry of ['node_modules/', '.nuxt/', '.output/', '.env', '.env.*', '!.env.example']) {
|
|
172
|
+
if (!ignore.split(/\r?\n/).includes(entry)) ignore = ignore.replace(/\n*$/, '\n') + entry + '\n';
|
|
173
|
+
}
|
|
174
|
+
await plan('.gitignore', ignore);
|
|
175
|
+
await plan('orionplace/settings.json', JSON.stringify(settings, null, 2) + '\n');
|
|
176
|
+
await plan('orionplace/.generated.json', JSON.stringify(manifest, null, 2) + '\n');
|
|
177
|
+
if (!dryRun) {
|
|
178
|
+
for (const [name, content] of changes) {
|
|
179
|
+
const target = await safePath(root, name);
|
|
180
|
+
if (content === null) { await unlink(target); continue; }
|
|
181
|
+
await mkdir(dirname(target), { recursive: true });
|
|
182
|
+
const temporary = `${target}.orionplace-${process.pid}.tmp`;
|
|
183
|
+
await writeFile(temporary, content, { mode: name === '.env' ? 0o600 : 0o644, flag: 'wx' });
|
|
184
|
+
await rename(temporary, target);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { root, settings, changed: [...changes.keys()], removed: [...changes].filter(([, content]) => content === null).map(([name]) => name), preserved, dryRun, isNewNuxt };
|
|
188
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
const dns = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
3
|
+
|
|
4
|
+
export function frontendDomain(value) {
|
|
5
|
+
let url;
|
|
6
|
+
try { url = new URL(value.includes('://') ? value : `https://${value}`); }
|
|
7
|
+
catch { throw new Error('Use a public frontend hostname, for example shop.example.com.'); }
|
|
8
|
+
const domain = url.hostname.toLowerCase().replace(/\.$/, '');
|
|
9
|
+
if (!dns.test(domain) || isIP(domain) || url.username || url.password || url.port ||
|
|
10
|
+
url.search || url.hash || url.pathname !== '/' || !['http:', 'https:'].includes(url.protocol)) {
|
|
11
|
+
throw new Error('Frontend must be a public hostname without a port, path, or credentials.');
|
|
12
|
+
}
|
|
13
|
+
return domain;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function backendUrl(value) {
|
|
17
|
+
let url;
|
|
18
|
+
try { url = new URL(value.includes('://') ? value : `https://${value}`); }
|
|
19
|
+
catch { throw new Error('Use the personal backend domain shown in your Orionplace account.'); }
|
|
20
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
21
|
+
if (url.protocol !== 'https:' || !dns.test(url.hostname) ||
|
|
22
|
+
!url.hostname.endsWith('.orionplace.app') || url.username || url.password ||
|
|
23
|
+
url.port || url.search || url.hash || !['', '/api'].includes(path)) {
|
|
24
|
+
throw new Error('Backend must be https://<your-project>.orionplace.app or its /api URL.');
|
|
25
|
+
}
|
|
26
|
+
return `${url.origin}/api`;
|
|
27
|
+
}
|