@fsn.dev/cli 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/assets/application.template.js +159 -0
- package/assets/fsn.cache-handler.nextjs.cjs +51 -0
- package/assets/fsn.host.nextjs.js +123 -0
- package/assets/fsn.host.sveltekit.js +4 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +54 -0
- package/dist/bin.js.map +1 -0
- package/dist/build-host.d.ts +8 -0
- package/dist/build-host.js +26 -0
- package/dist/build-host.js.map +1 -0
- package/dist/build.d.ts +1 -0
- package/dist/build.js +98 -0
- package/dist/build.js.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +26 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +121 -0
- package/dist/init.js.map +1 -0
- package/dist/package.d.ts +18 -0
- package/dist/package.js +63 -0
- package/dist/package.js.map +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +21 -0
- package/dist/utils.js.map +1 -0
- package/package.json +29 -6
- package/mise.toml +0 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ashwin Mahadevan
|
|
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.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { app, BrowserWindow, protocol, net, session } from 'electron';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
6
|
+
const SAME_SITE = { strict: 'strict', lax: 'lax', none: 'no_restriction' };
|
|
7
|
+
|
|
8
|
+
/** Apply one Set-Cookie header from a host response to Electron's cookie store. */
|
|
9
|
+
async function storeSetCookie(cookies, url, header) {
|
|
10
|
+
const [pair, ...attributes] = header.split(';');
|
|
11
|
+
const eq = pair.indexOf('=');
|
|
12
|
+
if (eq <= 0) return;
|
|
13
|
+
const cookie = { url, name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim() };
|
|
14
|
+
let expired = false;
|
|
15
|
+
|
|
16
|
+
for (const attribute of attributes) {
|
|
17
|
+
const [rawKey, ...rest] = attribute.split('=');
|
|
18
|
+
const key = rawKey.trim().toLowerCase();
|
|
19
|
+
const value = rest.join('=').trim();
|
|
20
|
+
if (key === 'path') cookie.path = value;
|
|
21
|
+
else if (key === 'domain') cookie.domain = value;
|
|
22
|
+
else if (key === 'secure') cookie.secure = true;
|
|
23
|
+
else if (key === 'httponly') cookie.httpOnly = true;
|
|
24
|
+
else if (key === 'samesite') cookie.sameSite = SAME_SITE[value.toLowerCase()] ?? 'unspecified';
|
|
25
|
+
else if (key === 'max-age') {
|
|
26
|
+
// Max-Age wins over Expires (RFC 6265 §5.3).
|
|
27
|
+
const seconds = Number(value);
|
|
28
|
+
if (seconds <= 0) expired = true;
|
|
29
|
+
else cookie.expirationDate = Date.now() / 1000 + seconds;
|
|
30
|
+
} else if (key === 'expires' && cookie.expirationDate === undefined) {
|
|
31
|
+
const time = Date.parse(value);
|
|
32
|
+
if (!Number.isNaN(time)) {
|
|
33
|
+
if (time <= Date.now()) expired = true;
|
|
34
|
+
else cookie.expirationDate = time / 1000;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (expired) {
|
|
41
|
+
const scope = new URL(url);
|
|
42
|
+
if (cookie.path) scope.pathname = cookie.path;
|
|
43
|
+
await cookies.remove(scope.href, cookie.name);
|
|
44
|
+
} else {
|
|
45
|
+
await cookies.set(cookie);
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`[fsn] couldn't store cookie "${cookie.name}" from ${url}:`, err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
app.whenReady().then(async () => {
|
|
53
|
+
// fsn build stages everything under app/ alongside this file.
|
|
54
|
+
const appDir = path.join(app.getAppPath(), 'app');
|
|
55
|
+
const configPath = path.join(appDir, 'fsn.config.js');
|
|
56
|
+
const { default: config } = await import(pathToFileURL(configPath).href);
|
|
57
|
+
|
|
58
|
+
// Where hosts may write at runtime (the app bundle itself is read-only).
|
|
59
|
+
process.env.FSN_DATA_DIR = app.getPath('userData');
|
|
60
|
+
|
|
61
|
+
const hosts = new Map();
|
|
62
|
+
const hostFor = (hostname) =>
|
|
63
|
+
hostname.endsWith('.localhost') ? hosts.get(hostname.slice(0, -'.localhost'.length)) : undefined;
|
|
64
|
+
|
|
65
|
+
// Server code fetching its own origin (e.g. Next.js fetching the target of
|
|
66
|
+
// a server action's redirect()) runs in this process, where requests don't
|
|
67
|
+
// pass through protocol.handle. Send those to the host in-process as well.
|
|
68
|
+
// Installed before hosts load so frameworks that wrap fetch wrap this one.
|
|
69
|
+
const nodeFetch = globalThis.fetch;
|
|
70
|
+
globalThis.fetch = async function fetch(input, init) {
|
|
71
|
+
const href = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
|
|
72
|
+
const url = new URL(href);
|
|
73
|
+
const host = hostFor(url.hostname);
|
|
74
|
+
if (!host) return nodeFetch(input, init);
|
|
75
|
+
|
|
76
|
+
const request = new Request(input, init);
|
|
77
|
+
// Hosts are only served over http; server code may assume https.
|
|
78
|
+
url.protocol = 'http:';
|
|
79
|
+
const response = await host.fetch(new Request(url, {
|
|
80
|
+
method: request.method,
|
|
81
|
+
headers: request.headers,
|
|
82
|
+
body: request.body,
|
|
83
|
+
duplex: 'half',
|
|
84
|
+
redirect: request.redirect,
|
|
85
|
+
signal: request.signal,
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const location = response.headers.get('location');
|
|
89
|
+
if (request.redirect !== 'follow' || !REDIRECT_STATUSES.has(response.status) || !location) {
|
|
90
|
+
return response;
|
|
91
|
+
}
|
|
92
|
+
// Follow redirects like fetch would; 303 (and 301/302 for POST) become GET.
|
|
93
|
+
const keepMethod = response.status === 307 || response.status === 308 ||
|
|
94
|
+
(request.method !== 'POST' && response.status !== 303);
|
|
95
|
+
return globalThis.fetch(new URL(location, url), keepMethod ? {
|
|
96
|
+
method: request.method, headers: request.headers, body: request.body, duplex: 'half',
|
|
97
|
+
} : { headers: request.headers });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// fsn build stages each host at app/<name>/, with a generated
|
|
101
|
+
// fsn.host.mjs that default-exports { fetch(request) => Response }.
|
|
102
|
+
for (const name of Object.keys(config.hosts)) {
|
|
103
|
+
const hostPath = path.join(appDir, name, 'fsn.host.mjs');
|
|
104
|
+
const { default: host } = await import(pathToFileURL(hostPath).href);
|
|
105
|
+
hosts.set(name, host);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Requests to http://<name>.localhost go straight to that host in-process:
|
|
109
|
+
// no port, no socket, not reachable from any other process.
|
|
110
|
+
//
|
|
111
|
+
// Chromium hands intercepted requests over without the headers its network
|
|
112
|
+
// stack would add (Cookie, Origin, Referer), and ignores Set-Cookie on the
|
|
113
|
+
// responses. So cookies go through the session's cookie store here, and
|
|
114
|
+
// Origin/Referer are rebuilt from request.referrer, which Chromium sets and
|
|
115
|
+
// a page can only shorten, never forge.
|
|
116
|
+
const cookies = session.defaultSession.cookies;
|
|
117
|
+
protocol.handle('http', async (request) => {
|
|
118
|
+
const host = hostFor(new URL(request.url).hostname);
|
|
119
|
+
if (!host) return net.fetch(request, { bypassCustomProtocolHandlers: true });
|
|
120
|
+
|
|
121
|
+
const headers = new Headers(request.headers);
|
|
122
|
+
const jar = await cookies.get({ url: request.url });
|
|
123
|
+
if (jar.length > 0) headers.set('cookie', jar.map((c) => `${c.name}=${c.value}`).join('; '));
|
|
124
|
+
if (request.referrer && request.referrer !== 'about:client') {
|
|
125
|
+
headers.set('referer', request.referrer);
|
|
126
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
127
|
+
headers.set('origin', new URL(request.referrer).origin);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const response = await host.fetch(new Request(request.url, {
|
|
132
|
+
method: request.method,
|
|
133
|
+
headers,
|
|
134
|
+
body: request.body,
|
|
135
|
+
duplex: 'half',
|
|
136
|
+
redirect: 'manual',
|
|
137
|
+
signal: request.signal,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
for (const header of response.headers.getSetCookie()) {
|
|
141
|
+
await storeSetCookie(cookies, request.url, header);
|
|
142
|
+
}
|
|
143
|
+
return response;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
for (const name of Object.keys(config.hosts)) {
|
|
147
|
+
const win = new BrowserWindow({
|
|
148
|
+
width: 1024,
|
|
149
|
+
height: 768,
|
|
150
|
+
title: name,
|
|
151
|
+
webPreferences: { sandbox: true },
|
|
152
|
+
});
|
|
153
|
+
win.loadURL(`http://${name}.localhost/`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
app.on('window-all-closed', () => {
|
|
157
|
+
if (process.platform !== 'darwin') app.quit();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// FSN cache handler for Next.js (`cacheHandler` in the runtime config).
|
|
2
|
+
//
|
|
3
|
+
// Next's default FileSystemCache writes ISR / revalidation results into
|
|
4
|
+
// `.next/`, which in a packaged app sits inside the read-only app.asar. This
|
|
5
|
+
// is the same cache with an overlay filesystem: writes go to a per-host,
|
|
6
|
+
// per-build directory under Electron's userData; reads look there first and
|
|
7
|
+
// fall back to the build-time files in `.next/`.
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const FileSystemCache = require('next/dist/server/lib/incremental-cache/file-system-cache').default;
|
|
12
|
+
|
|
13
|
+
const hostDir = __dirname;
|
|
14
|
+
const nextDir = path.join(hostDir, '.next');
|
|
15
|
+
|
|
16
|
+
function cacheDir() {
|
|
17
|
+
const dataDir = process.env.FSN_DATA_DIR;
|
|
18
|
+
if (!dataDir) return null;
|
|
19
|
+
// Namespace by build so an app update never serves the previous build's pages.
|
|
20
|
+
const buildId = fs.readFileSync(path.join(nextDir, 'BUILD_ID'), 'utf8').trim();
|
|
21
|
+
return path.join(dataDir, 'next-cache', path.basename(hostDir), buildId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A CacheFs whose writes land in `overlayDir` and whose reads prefer it. */
|
|
25
|
+
function overlayFs(baseFs, overlayDir) {
|
|
26
|
+
const toOverlay = (file) => {
|
|
27
|
+
const rel = path.relative(nextDir, file);
|
|
28
|
+
return rel.startsWith('..') || path.isAbsolute(rel) ? null : path.join(overlayDir, rel);
|
|
29
|
+
};
|
|
30
|
+
const preferOverlay = (file) => {
|
|
31
|
+
const overlay = toOverlay(file);
|
|
32
|
+
return overlay && fs.existsSync(overlay) ? overlay : file;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
existsSync: (file) => fs.existsSync(preferOverlay(file)),
|
|
36
|
+
readFile: (file, ...rest) => baseFs.readFile(preferOverlay(file), ...rest),
|
|
37
|
+
readFileSync: (file, ...rest) => baseFs.readFileSync(preferOverlay(file), ...rest),
|
|
38
|
+
stat: (file) => baseFs.stat(preferOverlay(file)),
|
|
39
|
+
writeFile: (file, data) => baseFs.writeFile(toOverlay(file) ?? file, data),
|
|
40
|
+
mkdir: (dir) => baseFs.mkdir(toOverlay(dir) ?? dir),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class FsnCacheHandler extends FileSystemCache {
|
|
45
|
+
constructor(ctx) {
|
|
46
|
+
const dir = cacheDir();
|
|
47
|
+
super(dir && ctx.fs ? { ...ctx, fs: overlayFs(ctx.fs, dir) } : ctx);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = FsnCacheHandler;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// FSN host for Next.js, written by fsn build next to the app's .next/.
|
|
2
|
+
// Default export: { fetch(request: Request): Promise<Response> }.
|
|
3
|
+
//
|
|
4
|
+
// Next.js only exposes a Node-style (req, res) handler, so this wraps it in an
|
|
5
|
+
// http.Server that never listens and feeds it each Request over an in-memory
|
|
6
|
+
// socket pair. Node's own HTTP parser does the req/res plumbing.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import { Duplex, Readable } from 'node:stream';
|
|
13
|
+
import next from 'next';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
// Boot from the config Next serialized at build time, the way the standalone
|
|
18
|
+
// server.js does: next()'s `conf` option is ignored by the router server it
|
|
19
|
+
// starts, but __NEXT_PRIVATE_STANDALONE_CONFIG isn't. next.config.* isn't
|
|
20
|
+
// loaded at runtime.
|
|
21
|
+
const { config } = JSON.parse(
|
|
22
|
+
readFileSync(path.join(__dirname, '.next', 'required-server-files.json'), 'utf8')
|
|
23
|
+
);
|
|
24
|
+
// fsn build stages a cache handler that writes ISR results to userData
|
|
25
|
+
// instead of the read-only app bundle.
|
|
26
|
+
config.cacheHandler = path.join(__dirname, 'fsn.cache-handler.cjs');
|
|
27
|
+
|
|
28
|
+
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config);
|
|
29
|
+
const nextApp = next({ dev: false, dir: __dirname });
|
|
30
|
+
await nextApp.prepare();
|
|
31
|
+
// Next caches the loaded config per app directory; don't leak it to other hosts.
|
|
32
|
+
delete process.env.__NEXT_PRIVATE_STANDALONE_CONFIG;
|
|
33
|
+
const handle = nextApp.getRequestHandler();
|
|
34
|
+
|
|
35
|
+
const server = http.createServer((req, res) => handle(req, res));
|
|
36
|
+
|
|
37
|
+
/** A Duplex that passes for a net.Socket; writes go to its peer. */
|
|
38
|
+
class InProcessSocket extends Duplex {
|
|
39
|
+
constructor() {
|
|
40
|
+
super({ allowHalfOpen: true });
|
|
41
|
+
this.peer = null;
|
|
42
|
+
this.connecting = false;
|
|
43
|
+
this.pending = false;
|
|
44
|
+
this.encrypted = false;
|
|
45
|
+
this.remoteAddress = '127.0.0.1';
|
|
46
|
+
this.remotePort = 0;
|
|
47
|
+
this.localAddress = '127.0.0.1';
|
|
48
|
+
this.localPort = 0;
|
|
49
|
+
this.bytesRead = 0;
|
|
50
|
+
this.bytesWritten = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_read() {}
|
|
54
|
+
|
|
55
|
+
_write(chunk, _encoding, callback) {
|
|
56
|
+
this.bytesWritten += chunk.length;
|
|
57
|
+
this.peer.push(chunk);
|
|
58
|
+
callback();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setKeepAlive() { return this; }
|
|
62
|
+
setNoDelay() { return this; }
|
|
63
|
+
setTimeout() { return this; }
|
|
64
|
+
ref() { return this; }
|
|
65
|
+
unref() { return this; }
|
|
66
|
+
address() { return { address: '127.0.0.1', family: 'IPv4', port: 0 }; }
|
|
67
|
+
|
|
68
|
+
destroySoon() {
|
|
69
|
+
if (this.writable) this.end();
|
|
70
|
+
if (this.writableFinished) this.destroy();
|
|
71
|
+
else this.once('finish', () => this.destroy());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createSocketPair() {
|
|
76
|
+
const a = new InProcessSocket();
|
|
77
|
+
const b = new InProcessSocket();
|
|
78
|
+
a.peer = b;
|
|
79
|
+
b.peer = a;
|
|
80
|
+
a.on('finish', () => b.push(null));
|
|
81
|
+
b.on('finish', () => a.push(null));
|
|
82
|
+
return [a, b];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default {
|
|
86
|
+
/** @param {Request} request */
|
|
87
|
+
fetch(request) {
|
|
88
|
+
const url = new URL(request.url);
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const [serverSocket, clientSocket] = createSocketPair();
|
|
91
|
+
server.emit('connection', serverSocket);
|
|
92
|
+
|
|
93
|
+
const nodeRequest = http.request({
|
|
94
|
+
createConnection: (_options, callback) => {
|
|
95
|
+
process.nextTick(callback, null, clientSocket);
|
|
96
|
+
return clientSocket;
|
|
97
|
+
},
|
|
98
|
+
method: request.method,
|
|
99
|
+
path: url.pathname + url.search,
|
|
100
|
+
// A fetch Request has no Host header; without one Node sends
|
|
101
|
+
// `localhost`, and Next builds its own origin (e.g. for server action
|
|
102
|
+
// redirects) from it.
|
|
103
|
+
headers: { ...Object.fromEntries(request.headers), host: url.host },
|
|
104
|
+
}, (nodeResponse) => {
|
|
105
|
+
const headers = new Headers();
|
|
106
|
+
for (const [key, value] of Object.entries(nodeResponse.headers)) {
|
|
107
|
+
if (Array.isArray(value)) value.forEach((v) => headers.append(key, v));
|
|
108
|
+
else if (value != null) headers.set(key, value);
|
|
109
|
+
}
|
|
110
|
+
const status = nodeResponse.statusCode ?? 200;
|
|
111
|
+
// Response() rejects a body for HEAD and null-body statuses.
|
|
112
|
+
const hasBody = request.method !== 'HEAD' && ![101, 204, 205, 304].includes(status);
|
|
113
|
+
if (!hasBody) nodeResponse.resume();
|
|
114
|
+
resolve(new Response(hasBody ? Readable.toWeb(nodeResponse) : null, { status, headers }));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
nodeRequest.on('error', reject);
|
|
118
|
+
|
|
119
|
+
if (request.body) Readable.fromWeb(request.body).pipe(nodeRequest);
|
|
120
|
+
else nodeRequest.end();
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { build } from './build.js';
|
|
3
|
+
import { init } from './init.js';
|
|
4
|
+
const FRAMEWORKS = new Set(['sveltekit', 'nextjs']);
|
|
5
|
+
const USAGE = `fsn — Full-Stack Native
|
|
6
|
+
|
|
7
|
+
Commands:
|
|
8
|
+
fsn init [--project <dir>] [--framework <sveltekit|nextjs>]
|
|
9
|
+
Scaffold a new FSN project.
|
|
10
|
+
--project defaults to "." (must be empty),
|
|
11
|
+
--framework defaults to nextjs.
|
|
12
|
+
fsn build Build the current project into a native app.
|
|
13
|
+
|
|
14
|
+
Docs: https://fsn.dev/cli
|
|
15
|
+
`;
|
|
16
|
+
try {
|
|
17
|
+
if (process.argv[2] === '--help' || process.argv[2] === '-h' || process.argv.length === 2) {
|
|
18
|
+
process.stdout.write(USAGE);
|
|
19
|
+
}
|
|
20
|
+
else if (process.argv[2] === 'build' && process.argv.length === 3) {
|
|
21
|
+
await build();
|
|
22
|
+
}
|
|
23
|
+
else if (process.argv[2] === 'init') {
|
|
24
|
+
let project = '.';
|
|
25
|
+
let framework = 'nextjs';
|
|
26
|
+
for (let i = 3; i < process.argv.length; i += 2) {
|
|
27
|
+
const flag = process.argv[i];
|
|
28
|
+
if (flag !== '--project' && flag !== '--framework') {
|
|
29
|
+
throw new Error(`Unknown argument "${flag}".\n\n${USAGE}`);
|
|
30
|
+
}
|
|
31
|
+
const value = process.argv[i + 1];
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
throw new Error(`Missing value for ${flag}.`);
|
|
34
|
+
}
|
|
35
|
+
if (flag === '--project')
|
|
36
|
+
project = value;
|
|
37
|
+
else
|
|
38
|
+
framework = value;
|
|
39
|
+
}
|
|
40
|
+
if (!FRAMEWORKS.has(framework)) {
|
|
41
|
+
throw new Error(`Unknown framework "${framework}". Expected one of: ${[...FRAMEWORKS].join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
await init(project, framework);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
process.stderr.write(USAGE);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
process.stderr.write('[fsn] error: ' + (err instanceof Error && err.stack ? err.stack : String(err)) + '\n');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=bin.js.map
|
package/dist/bin.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC,CAAC;AAEpD,MAAM,KAAK,GAAG;;;;;;;;;;CAUb,CAAC;AAEF,IAAI,CAAC;IACH,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1F,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IAC9B,CAAC;SAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpE,MAAM,KAAK,EAAE,CAAC;IAChB,CAAC;SAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE,CAAC;QACtC,IAAI,OAAO,GAAG,GAAG,CAAC;QAClB,IAAI,SAAS,GAAG,QAAQ,CAAC;QACzB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAChD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAC7B,IAAI,IAAI,KAAK,WAAW,IAAI,IAAI,KAAK,aAAa,EAAE,CAAC;gBACnD,MAAM,IAAI,KAAK,CAAC,qBAAqB,IAAI,SAAS,KAAK,EAAE,CAAC,CAAC;YAC7D,CAAC;YACD,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAClC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,MAAM,IAAI,KAAK,CAAC,qBAAqB,IAAI,GAAG,CAAC,CAAC;YAChD,CAAC;YACD,IAAI,IAAI,KAAK,WAAW;gBAAE,OAAO,GAAG,KAAK,CAAC;;gBACrC,SAAS,GAAG,KAAK,CAAC;QACzB,CAAC;QACD,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;YAC/B,MAAM,IAAI,KAAK,CAAC,sBAAsB,SAAS,uBAAuB,CAAC,GAAG,UAAU,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACtG,CAAC;QACD,MAAM,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACjC,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC5B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAAC,OAAO,GAAG,EAAE,CAAC;IACb,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,eAAe,GAAG,CAAC,GAAG,YAAY,KAAK,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC7G,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a single named host in the current FSN project.
|
|
3
|
+
* Reads fsn.config.js to locate the host directory, installs dependencies
|
|
4
|
+
* if needed, then runs `pnpm build`.
|
|
5
|
+
*
|
|
6
|
+
* @param name Key in config.hosts, e.g. "sveltekit"
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildHost(name: string): Promise<void>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { log, exists, run } from './utils.js';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Build a single named host in the current FSN project.
|
|
6
|
+
* Reads fsn.config.js to locate the host directory, installs dependencies
|
|
7
|
+
* if needed, then runs `pnpm build`.
|
|
8
|
+
*
|
|
9
|
+
* @param name Key in config.hosts, e.g. "sveltekit"
|
|
10
|
+
*/
|
|
11
|
+
export async function buildHost(name) {
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
const config = await loadConfig(cwd);
|
|
14
|
+
const hostRelDir = config.hosts[name];
|
|
15
|
+
if (!hostRelDir) {
|
|
16
|
+
throw new Error(`Host "${name}" not found in fsn.config.js. Available: ${Object.keys(config.hosts).join(', ')}`);
|
|
17
|
+
}
|
|
18
|
+
const hostDir = resolve(cwd, hostRelDir);
|
|
19
|
+
log(`building host "${name}" in ${hostDir}`);
|
|
20
|
+
if (!await exists(join(hostDir, 'node_modules'))) {
|
|
21
|
+
await run('pnpm', ['install'], { cwd: hostDir });
|
|
22
|
+
}
|
|
23
|
+
await run('pnpm', ['build'], { cwd: hostDir });
|
|
24
|
+
log(`✓ host "${name}" built`);
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=build-host.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-host.js","sourceRoot":"","sources":["../src/build-host.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAY;IAC1C,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;IAC1B,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,GAAG,CAAC,CAAC;IACrC,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACtC,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CAAC,SAAS,IAAI,4CAA4C,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACnH,CAAC;IAED,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC;IACzC,GAAG,CAAC,kBAAkB,IAAI,QAAQ,OAAO,EAAE,CAAC,CAAC;IAE7C,IAAI,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC;QACjD,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC,SAAS,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAC;IACnD,CAAC;IAED,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAC;IAC/C,GAAG,CAAC,WAAW,IAAI,SAAS,CAAC,CAAC;AAChC,CAAC"}
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function build(): Promise<void>;
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, writeFile, readFile, readdir, lstat, rm, cp, copyFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { log, exists, run, readAsset } from './utils.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { buildHost } from './build-host.js';
|
|
7
|
+
import { packageApp } from './package.js';
|
|
8
|
+
export async function build() {
|
|
9
|
+
const cwd = process.cwd();
|
|
10
|
+
const config = await loadConfig(cwd);
|
|
11
|
+
// Make sure every host has been built before staging them.
|
|
12
|
+
for (const name of Object.keys(config.hosts)) {
|
|
13
|
+
await buildHost(name);
|
|
14
|
+
}
|
|
15
|
+
const workdir = await mkdtemp(join(tmpdir(), 'fsn-build-'));
|
|
16
|
+
log(`workdir: ${workdir}`);
|
|
17
|
+
const appDir = join(workdir, 'app');
|
|
18
|
+
await mkdir(appDir, { recursive: true });
|
|
19
|
+
for (const [name, hostRelDir] of Object.entries(config.hosts)) {
|
|
20
|
+
const hostDir = resolve(cwd, hostRelDir);
|
|
21
|
+
const appHostDir = join(appDir, name);
|
|
22
|
+
await mkdir(appHostDir, { recursive: true });
|
|
23
|
+
const hostPkg = JSON.parse(await readFile(join(hostDir, 'package.json'), 'utf8'));
|
|
24
|
+
const hostDeps = { ...hostPkg.dependencies, ...hostPkg.devDependencies };
|
|
25
|
+
const isSvelteKit = '@sveltejs/kit' in hostDeps;
|
|
26
|
+
const isNextjs = 'next' in hostDeps;
|
|
27
|
+
if (!isSvelteKit && !isNextjs) {
|
|
28
|
+
throw new Error(`Cannot detect framework for host "${name}" at ${hostDir}: ` +
|
|
29
|
+
`expected @sveltejs/kit or next in its package.json.`);
|
|
30
|
+
}
|
|
31
|
+
// Production dependencies (only `dependencies`, flattened).
|
|
32
|
+
await deployProd(appHostDir, { filter: hostPkg.name, cwd });
|
|
33
|
+
if (isSvelteKit) {
|
|
34
|
+
// @fsn.dev/adapter-sveltekit's build/: the server bundled with its
|
|
35
|
+
// devDependencies, plus client assets and prerendered pages.
|
|
36
|
+
const buildDir = join(hostDir, 'build');
|
|
37
|
+
if (!await exists(join(buildDir, 'index.js'))) {
|
|
38
|
+
throw new Error(`Host "${name}" has no build/index.js. Is @fsn.dev/adapter-sveltekit ` +
|
|
39
|
+
`its SvelteKit adapter?`);
|
|
40
|
+
}
|
|
41
|
+
await cp(buildDir, join(appHostDir, 'build'), { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
await layerNextjsBuild(hostDir, appHostDir);
|
|
45
|
+
}
|
|
46
|
+
// The entry the main process loads: { fetch(request) => Response }.
|
|
47
|
+
// .mjs, since the host's package.json may not declare "type": "module".
|
|
48
|
+
const framework = isSvelteKit ? 'sveltekit' : 'nextjs';
|
|
49
|
+
await writeFile(join(appHostDir, 'fsn.host.mjs'), await readAsset(`fsn.host.${framework}.js`));
|
|
50
|
+
}
|
|
51
|
+
// Stage fsn.config.js so the main process can read hosts at runtime.
|
|
52
|
+
await copyFile(join(cwd, 'fsn.config.js'), join(appDir, 'fsn.config.js'));
|
|
53
|
+
// Write the Electron main, which routes window requests to each host.
|
|
54
|
+
await writeFile(join(workdir, 'main.js'), await readAsset('application.template.js'));
|
|
55
|
+
await packageApp({ workdir, cwd, main: 'main.js' });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Install a host package's production dependencies into `dest`.
|
|
59
|
+
*
|
|
60
|
+
* pnpm's isolated install leaves node_modules full of symlinks into .pnpm/ —
|
|
61
|
+
* those break require() once the tree is asar-bundled. `pnpm deploy` rewrites
|
|
62
|
+
* the tree with all prod deps fully inlined and devDeps stripped, using
|
|
63
|
+
* --node-linker=hoisted so node_modules is flat (no .pnpm subdir). It also
|
|
64
|
+
* copies the package's published files; build output is layered on after.
|
|
65
|
+
*
|
|
66
|
+
* @param options
|
|
67
|
+
* `filter` is the package name for pnpm --filter; `cwd` is a directory
|
|
68
|
+
* inside the pnpm workspace that contains it.
|
|
69
|
+
*/
|
|
70
|
+
async function deployProd(dest, { filter, cwd }) {
|
|
71
|
+
// Non-legacy deploy needs a shared workspace lockfile (fsn init projects
|
|
72
|
+
// have one). Without one, fall back to --legacy — which newer pnpm (12)
|
|
73
|
+
// ignores when a lockfile exists, silently installing nothing.
|
|
74
|
+
const legacy = await exists(join(cwd, 'pnpm-lock.yaml')) ? [] : ['--legacy'];
|
|
75
|
+
await run('pnpm', ['--filter', filter, 'deploy', '--prod', ...legacy, '--config.node-linker=hoisted', dest], { cwd });
|
|
76
|
+
// Hoisted deploy leaves symlinks to sibling workspace packages in
|
|
77
|
+
// node_modules; once staged elsewhere they dangle and break packaging.
|
|
78
|
+
const nodeModules = join(dest, 'node_modules');
|
|
79
|
+
if (!await exists(nodeModules))
|
|
80
|
+
return;
|
|
81
|
+
for (const entry of await readdir(nodeModules)) {
|
|
82
|
+
const p = join(nodeModules, entry);
|
|
83
|
+
if ((await lstat(p)).isSymbolicLink() && !await exists(p))
|
|
84
|
+
await rm(p);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Copy a built Next.js app's build output into `dest`. The host boots from
|
|
89
|
+
* the config serialized in .next/, so no source or next.config.* is needed.
|
|
90
|
+
*/
|
|
91
|
+
async function layerNextjsBuild(srcDir, dest) {
|
|
92
|
+
await cp(join(srcDir, '.next'), join(dest, '.next'), { recursive: true });
|
|
93
|
+
if (await exists(join(srcDir, 'public'))) {
|
|
94
|
+
await cp(join(srcDir, 'public'), join(dest, 'public'), { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
await writeFile(join(dest, 'fsn.cache-handler.cjs'), await readAsset('fsn.cache-handler.nextjs.cjs'));
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=build.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build.js","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"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"}
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { exists } from './utils.js';
|
|
4
|
+
/** Load and validate fsn.config.js from a project directory. */
|
|
5
|
+
export async function loadConfig(cwd) {
|
|
6
|
+
const configPath = join(cwd, 'fsn.config.js');
|
|
7
|
+
if (!await exists(configPath)) {
|
|
8
|
+
throw new Error(`fsn.config.js not found in ${cwd}`);
|
|
9
|
+
}
|
|
10
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
11
|
+
const config = mod.default || mod;
|
|
12
|
+
if (!config) {
|
|
13
|
+
throw new Error(`fsn.config.js must default-export a Config object`);
|
|
14
|
+
}
|
|
15
|
+
if (typeof config.hosts !== 'object' || config.hosts === null || Object.keys(config.hosts).length === 0) {
|
|
16
|
+
throw new Error(`fsn.config.js: hosts must be a non-empty object mapping names to package directories`);
|
|
17
|
+
}
|
|
18
|
+
for (const [name, hostPath] of Object.entries(config.hosts)) {
|
|
19
|
+
if (typeof hostPath !== 'string' || !await exists(join(resolve(cwd, hostPath), 'package.json'))) {
|
|
20
|
+
throw new Error(`fsn.config.js: hosts.${name} must be the path to a package directory ` +
|
|
21
|
+
`(e.g. './native'), but ${JSON.stringify(hostPath)} has no package.json.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return config;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAGpC,gEAAgE;AAChE,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,GAAW;IAC1C,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC,CAAC;IAC9C,IAAI,CAAC,MAAM,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,8BAA8B,GAAG,EAAE,CAAC,CAAC;IACvD,CAAC;IACD,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC;IACzD,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC;IAClC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACvE,CAAC;IACD,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,KAAK,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxG,MAAM,IAAI,KAAK,CAAC,sFAAsF,CAAC,CAAC;IAC1G,CAAC;IACD,KAAK,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5D,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC;YAChG,MAAM,IAAI,KAAK,CACb,wBAAwB,IAAI,2CAA2C;gBACvE,0BAA0B,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,uBAAuB,CAC1E,CAAC;QACJ,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type Config = {
|
|
2
|
+
/**
|
|
3
|
+
* Host name → path (relative to fsn.config.js) of a SvelteKit or Next.js
|
|
4
|
+
* package. Each host gets a window at `http://<host-name>.localhost/`.
|
|
5
|
+
*/
|
|
6
|
+
hosts: Record<string, string>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* What `fsn build` generates for each host (app/<name>/fsn.host.mjs). The
|
|
10
|
+
* FSN main process passes it each request made to `http://<name>.localhost`.
|
|
11
|
+
*/
|
|
12
|
+
export type Host = {
|
|
13
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
14
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(projectName: string, framework: string): Promise<void>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readdir, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { resolve, join, basename } from 'node:path';
|
|
3
|
+
import { log, exists, run } from './utils.js';
|
|
4
|
+
export async function init(projectName, framework) {
|
|
5
|
+
const projectDir = resolve(projectName);
|
|
6
|
+
if (await exists(projectDir)) {
|
|
7
|
+
const entries = await readdir(projectDir);
|
|
8
|
+
if (entries.length > 0) {
|
|
9
|
+
throw new Error(`${projectDir} already exists and is not empty.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
await mkdir(projectDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
if (framework === 'sveltekit') {
|
|
16
|
+
await scaffoldSveltekit(projectDir);
|
|
17
|
+
}
|
|
18
|
+
else if (framework === 'nextjs') {
|
|
19
|
+
await scaffoldNextjs(projectDir);
|
|
20
|
+
}
|
|
21
|
+
await writeWorkspaceFiles(projectDir);
|
|
22
|
+
log(`done. Next:`);
|
|
23
|
+
if (projectDir !== process.cwd())
|
|
24
|
+
log(` cd ${projectName}`);
|
|
25
|
+
log(` pnpm install`);
|
|
26
|
+
log(` pnpm build`);
|
|
27
|
+
}
|
|
28
|
+
async function scaffoldSveltekit(projectDir) {
|
|
29
|
+
// sv create scaffolds into ./<dir>; we run it with cwd=projectDir so the
|
|
30
|
+
// skeleton lands in projectDir/native.
|
|
31
|
+
await run('pnpm', [
|
|
32
|
+
'dlx',
|
|
33
|
+
'sv@latest',
|
|
34
|
+
'create',
|
|
35
|
+
'native',
|
|
36
|
+
'--template', 'demo',
|
|
37
|
+
'--types', 'ts',
|
|
38
|
+
'--no-add-ons',
|
|
39
|
+
'--no-install',
|
|
40
|
+
'--no-dir-check',
|
|
41
|
+
'--no-download-check',
|
|
42
|
+
], { cwd: projectDir });
|
|
43
|
+
// Swap the default adapter-auto for FSN's adapter, which builds the app
|
|
44
|
+
// into a host the Electron main process calls in-process — this is the
|
|
45
|
+
// whole point of FSN: ship fullstack frameworks, not static exports.
|
|
46
|
+
const nativeDir = join(projectDir, 'native');
|
|
47
|
+
// Newer sv templates put the kit config in vite.config.ts; older ones use
|
|
48
|
+
// svelte.config.js. Patch whichever imports adapter-auto.
|
|
49
|
+
let patched = false;
|
|
50
|
+
for (const file of ['svelte.config.js', 'vite.config.ts', 'vite.config.js']) {
|
|
51
|
+
const configPath = join(nativeDir, file);
|
|
52
|
+
if (!await exists(configPath))
|
|
53
|
+
continue;
|
|
54
|
+
const source = await readFile(configPath, 'utf8');
|
|
55
|
+
if (!source.includes(`'@sveltejs/adapter-auto'`))
|
|
56
|
+
continue;
|
|
57
|
+
await writeFile(configPath, source.replace(`'@sveltejs/adapter-auto'`, `'@fsn.dev/adapter-sveltekit'`));
|
|
58
|
+
patched = true;
|
|
59
|
+
}
|
|
60
|
+
if (!patched) {
|
|
61
|
+
throw new Error(`Couldn't find the @sveltejs/adapter-auto import in ${nativeDir} to replace.`);
|
|
62
|
+
}
|
|
63
|
+
const pkgPath = join(nativeDir, 'package.json');
|
|
64
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
65
|
+
delete pkg.devDependencies['@sveltejs/adapter-auto'];
|
|
66
|
+
pkg.devDependencies['@fsn.dev/adapter-sveltekit'] = '*';
|
|
67
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, '\t') + '\n');
|
|
68
|
+
await removeNestedWorkspace(nativeDir);
|
|
69
|
+
}
|
|
70
|
+
async function scaffoldNextjs(projectDir) {
|
|
71
|
+
// create-next-app doesn't accept a relative path with trailing components;
|
|
72
|
+
// running it with cwd=projectDir lands the project in projectDir/native.
|
|
73
|
+
await run('pnpm', [
|
|
74
|
+
'dlx',
|
|
75
|
+
'create-next-app@latest',
|
|
76
|
+
'native',
|
|
77
|
+
'--yes',
|
|
78
|
+
'--ts',
|
|
79
|
+
'--eslint',
|
|
80
|
+
'--app',
|
|
81
|
+
'--src-dir',
|
|
82
|
+
'--no-tailwind',
|
|
83
|
+
'--import-alias', '@/*',
|
|
84
|
+
'--use-pnpm',
|
|
85
|
+
'--skip-install',
|
|
86
|
+
'--disable-git',
|
|
87
|
+
], { cwd: projectDir });
|
|
88
|
+
const nativeDir = join(projectDir, 'native');
|
|
89
|
+
await removeNestedWorkspace(nativeDir);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* create-next-app and sv both drop a pnpm-workspace.yaml inside the package
|
|
93
|
+
* to configure build scripts. Our parent workspace already controls those via
|
|
94
|
+
* `onlyBuiltDependencies`, and a nested workspace yaml confuses pnpm.
|
|
95
|
+
*/
|
|
96
|
+
async function removeNestedWorkspace(nativeDir) {
|
|
97
|
+
const nestedWs = join(nativeDir, 'pnpm-workspace.yaml');
|
|
98
|
+
if (await exists(nestedWs))
|
|
99
|
+
await rm(nestedWs);
|
|
100
|
+
}
|
|
101
|
+
async function writeWorkspaceFiles(projectDir) {
|
|
102
|
+
await writeFile(join(projectDir, 'pnpm-workspace.yaml'),
|
|
103
|
+
// Both templates pull in sharp (Next.js directly, SvelteKit via
|
|
104
|
+
// enhanced-img), whose build script pnpm 10+ refuses to run unless it's
|
|
105
|
+
// explicitly approved.
|
|
106
|
+
`packages:\n - native\n\nonlyBuiltDependencies:\n - electron\n\nallowBuilds:\n sharp: true\n`);
|
|
107
|
+
await writeFile(join(projectDir, 'package.json'), JSON.stringify({
|
|
108
|
+
name: basename(projectDir),
|
|
109
|
+
private: true,
|
|
110
|
+
version: '0.0.0',
|
|
111
|
+
scripts: {
|
|
112
|
+
build: 'fsn build',
|
|
113
|
+
},
|
|
114
|
+
devDependencies: {
|
|
115
|
+
'@fsn.dev/cli': '*',
|
|
116
|
+
},
|
|
117
|
+
}, null, 2) + '\n');
|
|
118
|
+
await writeFile(join(projectDir, 'fsn.config.js'), `/** @type {import('@fsn.dev/cli').Config} */\nexport default {\n hosts: {\n native: './native',\n },\n};\n`);
|
|
119
|
+
await writeFile(join(projectDir, '.gitignore'), `node_modules\ndist\n.DS_Store\n*.log\n`);
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=init.js.map
|
package/dist/init.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"init.js","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a staged workdir (Electron main file + app/) into a packaged native app
|
|
3
|
+
* and copy it to <cwd>/dist.
|
|
4
|
+
*
|
|
5
|
+
* Writes the Electron app's package.json, installs Electron and
|
|
6
|
+
* @electron/packager, runs the packager for the current platform/arch, then
|
|
7
|
+
* replaces <cwd>/dist with the result. We use npm here (not pnpm) because the
|
|
8
|
+
* workdir is throwaway and npm doesn't gate Electron's post-install download.
|
|
9
|
+
*
|
|
10
|
+
* @param options.workdir Directory containing `main` and `app/`.
|
|
11
|
+
* @param options.cwd The user's project directory.
|
|
12
|
+
* @param options.main Filename of the Electron main process in workdir.
|
|
13
|
+
*/
|
|
14
|
+
export declare function packageApp({ workdir, cwd, main }: {
|
|
15
|
+
workdir: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
main: string;
|
|
18
|
+
}): Promise<void>;
|
package/dist/package.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { writeFile, readFile, rm, cp } from 'node:fs/promises';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { log, exists, run } from './utils.js';
|
|
4
|
+
function sanitizeAppName(name) {
|
|
5
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '') || 'app';
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The Electron and @electron/packager versions to build with. They're pinned
|
|
9
|
+
* in this package's devDependencies (exact versions) so builds are
|
|
10
|
+
* reproducible and bumped like any other dependency.
|
|
11
|
+
*/
|
|
12
|
+
async function pinnedVersions() {
|
|
13
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
14
|
+
const result = {};
|
|
15
|
+
for (const name of ['electron', '@electron/packager']) {
|
|
16
|
+
const version = pkg.devDependencies?.[name];
|
|
17
|
+
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+/.test(version)) {
|
|
18
|
+
throw new Error(`@fsn.dev/cli is missing a pinned version for ${name}.`);
|
|
19
|
+
}
|
|
20
|
+
result[name] = version;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Turn a staged workdir (Electron main file + app/) into a packaged native app
|
|
26
|
+
* and copy it to <cwd>/dist.
|
|
27
|
+
*
|
|
28
|
+
* Writes the Electron app's package.json, installs Electron and
|
|
29
|
+
* @electron/packager, runs the packager for the current platform/arch, then
|
|
30
|
+
* replaces <cwd>/dist with the result. We use npm here (not pnpm) because the
|
|
31
|
+
* workdir is throwaway and npm doesn't gate Electron's post-install download.
|
|
32
|
+
*
|
|
33
|
+
* @param options.workdir Directory containing `main` and `app/`.
|
|
34
|
+
* @param options.cwd The user's project directory.
|
|
35
|
+
* @param options.main Filename of the Electron main process in workdir.
|
|
36
|
+
*/
|
|
37
|
+
export async function packageApp({ workdir, cwd, main }) {
|
|
38
|
+
const versions = await pinnedVersions();
|
|
39
|
+
const appName = sanitizeAppName(basename(cwd));
|
|
40
|
+
await writeFile(join(workdir, 'package.json'), JSON.stringify({ name: appName, version: '0.0.0', private: true, type: 'module', main }, null, 2) + '\n');
|
|
41
|
+
await run('npm', ['install', '--no-audit', '--no-fund', '--save-dev',
|
|
42
|
+
`electron@${versions.electron}`,
|
|
43
|
+
`@electron/packager@${versions['@electron/packager']}`,
|
|
44
|
+
], { cwd: workdir });
|
|
45
|
+
const platform = process.platform === 'darwin' ? 'darwin'
|
|
46
|
+
: process.platform === 'win32' ? 'win32' : 'linux';
|
|
47
|
+
await run('npx', ['--no-install', '@electron/packager', '.', appName,
|
|
48
|
+
'--out', 'dist',
|
|
49
|
+
'--overwrite',
|
|
50
|
+
'--platform', platform,
|
|
51
|
+
'--arch', process.arch,
|
|
52
|
+
], { cwd: workdir });
|
|
53
|
+
const projectDist = join(cwd, 'dist');
|
|
54
|
+
if (await exists(projectDist)) {
|
|
55
|
+
await rm(projectDist, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
// verbatimSymlinks: keep the .app's relative framework symlinks intact.
|
|
58
|
+
// Without it, fs.cp rewrites them to absolute paths into the temp workdir,
|
|
59
|
+
// which breaks the packaged app (e.g. Electron can't find icudtl.dat).
|
|
60
|
+
await cp(join(workdir, 'dist'), projectDist, { recursive: true, verbatimSymlinks: true });
|
|
61
|
+
log(`✓ Built native app at ${projectDist}`);
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=package.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"package.js","sourceRoot":"","sources":["../src/package.ts"],"names":[],"mappings":"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"}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function log(...args: unknown[]): void;
|
|
2
|
+
export declare const exists: (p: string) => Promise<boolean>;
|
|
3
|
+
export declare function run(cmd: string, args: string[], options?: {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
export declare function readAsset(name: string): Promise<string>;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
export function log(...args) {
|
|
4
|
+
process.stderr.write('[fsn] ' + args.join(' ') + '\n');
|
|
5
|
+
}
|
|
6
|
+
export const exists = (p) => access(p).then(() => true, () => false);
|
|
7
|
+
export async function run(cmd, args, options = {}) {
|
|
8
|
+
log('$', cmd, args.join(' '), options.cwd ? `(cwd=${options.cwd})` : '');
|
|
9
|
+
const child = spawn(cmd, args, { stdio: 'inherit', shell: false, ...options });
|
|
10
|
+
const code = await new Promise((resolve, reject) => {
|
|
11
|
+
child.on('close', resolve);
|
|
12
|
+
child.on('error', reject);
|
|
13
|
+
});
|
|
14
|
+
if (code !== 0) {
|
|
15
|
+
throw new Error(`${cmd} ${args.join(' ')} exited with code ${code}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function readAsset(name) {
|
|
19
|
+
return readFile(new URL('../assets/' + name, import.meta.url), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C,MAAM,UAAU,GAAG,CAAC,GAAG,IAAe;IACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;AACzD,CAAC;AAED,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,CAAS,EAAoB,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC;AAE/F,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,GAAW,EAAE,IAAc,EAAE,UAA4B,EAAE;IACnF,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACzE,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IAC/E,MAAM,IAAI,GAAG,MAAM,IAAI,OAAO,CAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QAChE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC3B,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAC5B,CAAC,CAAC,CAAC;IACH,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CAAC,GAAG,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,qBAAqB,IAAI,EAAE,CAAC,CAAC;IACvE,CAAC;AACH,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,IAAY;IACpC,OAAO,QAAQ,CAAC,IAAI,GAAG,CAAC,YAAY,GAAG,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;AACzE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fsn.dev/cli",
|
|
3
|
-
"
|
|
3
|
+
"description": "FSN: Full-Stack Native. Use any full-stack framework to create a native app!",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/ashwin-mahadevan/fsn.git"
|
|
7
|
+
"url": "git+https://github.com/ashwin-mahadevan/fsn.git",
|
|
8
|
+
"directory": "packages/cli"
|
|
8
9
|
},
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
|
|
10
|
+
"homepage": "https://fsn.dev",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"version": "0.1.0",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"bin": {
|
|
19
|
+
"fsn": "./dist/bin.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist/",
|
|
23
|
+
"assets/"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.9.0",
|
|
30
|
+
"typescript": "^5.9.0",
|
|
31
|
+
"@electron/packager": "20.3.0",
|
|
32
|
+
"electron": "45.2.3"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc"
|
|
13
36
|
}
|
|
14
37
|
}
|
package/mise.toml
DELETED