@fate-app/mod-build 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Stanislav Sonder
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,28 @@
1
+ # @fate-app/mod-build
2
+
3
+ Vite build preset + dev-mode server for authoring [Assistant for Fate](https://github.com/Stanislavsonder/fate)
4
+ mods — externalizes host-shared libraries (`vue`, `vue-i18n`, `@ionic/vue`,
5
+ `ionicons`) against `window.FateSDK` instead of bundling them, inlines CSS,
6
+ and enforces bundle size limits.
7
+
8
+ ```ts
9
+ // vite.config.ts
10
+ import { defineModConfig } from '@fate-app/mod-build'
11
+
12
+ export default defineModConfig()
13
+ ```
14
+
15
+ `./testing` exposes `stubFateSDK()`/`smokeLoad()` for headlessly verifying a
16
+ built bundle's shape outside a real app (used by the registry's CI).
17
+
18
+ Full author-facing documentation lives in the app repo's
19
+ [`docs/MOD_API.md`](https://github.com/Stanislavsonder/fate/blob/main/docs/MOD_API.md).
20
+ Scaffold a new mod project with `pnpm create fate-mod` rather than hand-rolling
21
+ this config.
22
+
23
+ ## Version discipline
24
+
25
+ This package's version tracks `SDK_VERSION` (the `FateSDK` ABI, defined in
26
+ the app's `src/mods/sdk.ts`) — same major.minor, patch is free. Don't pin a
27
+ version here that doesn't correspond to a real `SDK_VERSION` the app has
28
+ shipped.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { startDevServer } from "./dev.js";
4
+ /**
5
+ * Published as the `fate-mod-build` binary (package.json `bin`). A real mod
6
+ * project (outside this monorepo, e.g. one scaffolded by `create-fate-mod`)
7
+ * runs this via its own `package.json` scripts: `"dev": "fate-mod-build dev"`,
8
+ * `"build": "fate-mod-build build"`. Inside this monorepo, `example-mod`
9
+ * still invokes `dev.ts`'s `startDevServer` directly via `devCli.ts` — kept
10
+ * as-is rather than migrated, since it predates this bin and works fine.
11
+ */
12
+ async function main() {
13
+ const [, , command] = process.argv;
14
+ const root = process.cwd();
15
+ if (command === 'dev') {
16
+ const port = Number(process.env.PORT) || 5199;
17
+ await startDevServer({ root, port });
18
+ return;
19
+ }
20
+ if (command === 'build') {
21
+ await runBuild(root);
22
+ return;
23
+ }
24
+ console.error(`Unknown command "${command ?? ''}". Usage: fate-mod-build <dev|build>`);
25
+ process.exit(1);
26
+ }
27
+ /**
28
+ * Shells `vite build` (no --watch) once. See dev.ts's startDevServer for why
29
+ * this spawns the CLI (shell:true, a fixed non-interpolated command string)
30
+ * rather than Vite's programmatic build() API or an argv array.
31
+ */
32
+ function runBuild(root) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawn('npx vite build', { cwd: root, stdio: 'inherit', shell: true });
35
+ child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`vite build exited with code ${code}`))));
36
+ child.on('error', reject);
37
+ });
38
+ }
39
+ main().catch((e) => {
40
+ console.error(e);
41
+ process.exit(1);
42
+ });
package/dist/dev.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface DevServerOptions {
2
+ /** The mod project root (where manifest.json / translations/ / vite.config.ts live). */
3
+ root: string;
4
+ /** Where the watched build writes bundle.mjs — defaults to <root>/dist. */
5
+ distDir?: string;
6
+ port?: number;
7
+ }
8
+ export interface DevServerHandle {
9
+ close(): Promise<void>;
10
+ }
11
+ /**
12
+ * The author-side half of Developer Mode live reload (see src/mods/devMode.ts
13
+ * for the app-side client). Spawns `vite build --watch` as a child process —
14
+ * deliberately NOT Vite's programmatic build() API: at the time this was
15
+ * written (Vite 8 / rolldown-vite), build({ watch: {} }) produced a
16
+ * corrupted/incomplete build (dropped modules, SFC parse errors) that the
17
+ * plain CLI command does not, in this exact project. Spawning the CLI
18
+ * reuses the code path that's actually proven to work, and decouples "run
19
+ * the watched build" from "serve files + notify" — a plain fs.watch on the
20
+ * output directory drives the SSE `/events` endpoint the app subscribes to.
21
+ */
22
+ export declare function startDevServer(options: DevServerOptions): Promise<DevServerHandle>;
package/dist/dev.js ADDED
@@ -0,0 +1,99 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { existsSync, watch as watchFs } from 'node:fs';
4
+ import { join, extname } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ const MIME = {
7
+ '.json': 'application/json',
8
+ '.mjs': 'text/javascript',
9
+ '.js': 'text/javascript'
10
+ };
11
+ /**
12
+ * The author-side half of Developer Mode live reload (see src/mods/devMode.ts
13
+ * for the app-side client). Spawns `vite build --watch` as a child process —
14
+ * deliberately NOT Vite's programmatic build() API: at the time this was
15
+ * written (Vite 8 / rolldown-vite), build({ watch: {} }) produced a
16
+ * corrupted/incomplete build (dropped modules, SFC parse errors) that the
17
+ * plain CLI command does not, in this exact project. Spawning the CLI
18
+ * reuses the code path that's actually proven to work, and decouples "run
19
+ * the watched build" from "serve files + notify" — a plain fs.watch on the
20
+ * output directory drives the SSE `/events` endpoint the app subscribes to.
21
+ */
22
+ export async function startDevServer(options) {
23
+ const { root } = options;
24
+ const port = options.port ?? 5199;
25
+ const distDir = options.distDir ?? join(root, 'dist');
26
+ const clients = new Set();
27
+ function notifyRebuild() {
28
+ for (const res of clients) {
29
+ res.write('data: rebuild\n\n');
30
+ }
31
+ }
32
+ // Windows needs shell:true to run npx (a .cmd file) at all; combining shell:true
33
+ // with an argv array triggers Node's DEP0190 warning (args aren't escaped for
34
+ // the shell), so pass one fixed, non-interpolated command string instead —
35
+ // nothing here is ever built from user input.
36
+ const child = spawn('npx vite build --watch', {
37
+ cwd: root,
38
+ stdio: 'inherit',
39
+ shell: true
40
+ });
41
+ // Watches `root` (not distDir directly) because distDir may not exist yet at
42
+ // startup (e.g. right after `rm -rf dist`) — fs.watch on a not-yet-existing
43
+ // path never fires for anything created later, silently going dead for the
44
+ // whole session. `root` always exists; filter to changes under dist/.
45
+ // Debounced: a rebuild touches multiple files in dist/ in quick succession.
46
+ let debounceTimer;
47
+ const fsWatcher = watchFs(root, { recursive: true }, (_event, filename) => {
48
+ if (!filename || !filename.split(/[\\/]/).includes('dist')) {
49
+ return;
50
+ }
51
+ clearTimeout(debounceTimer);
52
+ debounceTimer = setTimeout(notifyRebuild, 150);
53
+ });
54
+ const server = createHttpServer(async (req, res) => {
55
+ res.setHeader('Access-Control-Allow-Origin', '*');
56
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
57
+ if (url.pathname === '/events') {
58
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
59
+ res.write('\n');
60
+ clients.add(res);
61
+ req.on('close', () => clients.delete(res));
62
+ return;
63
+ }
64
+ const relative = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
65
+ // dist/ (the built bundle) takes priority; everything else (manifest.json,
66
+ // translations/) is served straight from the project root.
67
+ const distPath = join(distDir, relative);
68
+ const filePath = existsSync(distPath) ? distPath : join(root, relative);
69
+ try {
70
+ const stats = await stat(filePath);
71
+ if (!stats.isFile()) {
72
+ throw new Error('not a file');
73
+ }
74
+ const contents = await readFile(filePath);
75
+ res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] ?? 'application/octet-stream' });
76
+ res.end(contents);
77
+ }
78
+ catch {
79
+ res.writeHead(404);
80
+ res.end('Not found');
81
+ }
82
+ });
83
+ await new Promise((resolve, reject) => {
84
+ server.once('error', reject);
85
+ server.listen(port, resolve);
86
+ });
87
+ console.log(`[mod-build dev] serving ${root} at http://localhost:${port} (SSE: /events)`);
88
+ return {
89
+ async close() {
90
+ for (const res of clients) {
91
+ res.end();
92
+ }
93
+ clients.clear();
94
+ fsWatcher?.close();
95
+ child.kill();
96
+ await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
97
+ }
98
+ };
99
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/devCli.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Minimal CLI entry for `pnpm dev` in a mod project. Not a published binary
3
+ * in this phase (Decision #6 in the Phase 2 plan — mod-build only needs to
4
+ * work from inside this monorepo). A real mod project runs this directly:
5
+ * `node --experimental-transform-types <path-to>/devCli.ts`.
6
+ */
7
+ import { startDevServer } from "./dev.js";
8
+ const root = process.cwd();
9
+ const port = Number(process.env.PORT) || 5199;
10
+ startDevServer({ root, port }).catch((e) => {
11
+ console.error(e);
12
+ process.exit(1);
13
+ });
@@ -0,0 +1,17 @@
1
+ import type { Plugin } from 'vite';
2
+ /**
3
+ * Rewrites imports of host-shared libraries (vue, vue-i18n, @ionic/vue,
4
+ * ionicons/icons — decision D2 in README.md) into virtual modules that
5
+ * re-export named bindings from `globalThis.FateSDK.*` instead of bundling
6
+ * the library. This is what keeps the mod using the HOST's Vue instance
7
+ * (required for reactivity/provide-inject to work across the bundle
8
+ * boundary) while still letting mod authors write plain
9
+ * `import { ref } from 'vue'`.
10
+ *
11
+ * Export lists come from the pinned `sdkExports.ts` (see
12
+ * scripts/generateSdkExports.ts), not a live `import()` at build time — the
13
+ * latter resolves Node's CJS-interop build of each package (via the "node"
14
+ * export condition) rather than the browser ESM build Vite/the app actually
15
+ * use, producing a polluted/incomplete export list.
16
+ */
17
+ export declare function fateSdkShims(externals: Record<string, string>): Plugin;
@@ -0,0 +1,39 @@
1
+ import { SDK_EXPORTS } from "./sdkExports.js";
2
+ const VIRTUAL_PREFIX = '\0fate-sdk-shim:';
3
+ /**
4
+ * Rewrites imports of host-shared libraries (vue, vue-i18n, @ionic/vue,
5
+ * ionicons/icons — decision D2 in README.md) into virtual modules that
6
+ * re-export named bindings from `globalThis.FateSDK.*` instead of bundling
7
+ * the library. This is what keeps the mod using the HOST's Vue instance
8
+ * (required for reactivity/provide-inject to work across the bundle
9
+ * boundary) while still letting mod authors write plain
10
+ * `import { ref } from 'vue'`.
11
+ *
12
+ * Export lists come from the pinned `sdkExports.ts` (see
13
+ * scripts/generateSdkExports.ts), not a live `import()` at build time — the
14
+ * latter resolves Node's CJS-interop build of each package (via the "node"
15
+ * export condition) rather than the browser ESM build Vite/the app actually
16
+ * use, producing a polluted/incomplete export list.
17
+ */
18
+ export function fateSdkShims(externals) {
19
+ return {
20
+ name: 'fate-sdk-shims',
21
+ enforce: 'pre',
22
+ resolveId(id) {
23
+ return id in externals ? VIRTUAL_PREFIX + id : null;
24
+ },
25
+ load(id) {
26
+ if (!id.startsWith(VIRTUAL_PREFIX)) {
27
+ return null;
28
+ }
29
+ const specifier = id.slice(VIRTUAL_PREFIX.length);
30
+ const globalPath = externals[specifier];
31
+ const names = SDK_EXPORTS[specifier];
32
+ if (!names) {
33
+ this.error(`No pinned export list for "${specifier}" — add it in sdkExports.ts (see generateSdkExports.ts).`);
34
+ }
35
+ const lines = [`const m = globalThis.${globalPath};`, ...names.map(name => `export const ${name} = m.${name};`)];
36
+ return lines.join('\n');
37
+ }
38
+ };
39
+ }
@@ -0,0 +1,14 @@
1
+ import { type UserConfig } from 'vite';
2
+ /**
3
+ * Host-shared libraries a mod must not bundle its own copy of (decision D2).
4
+ * Keys are the import specifiers a mod author writes; values are the
5
+ * `globalThis.FateSDK` path the host installs before loading any mod.
6
+ */
7
+ export declare const EXTERNALS: Record<string, string>;
8
+ /**
9
+ * The Vite config a mod project's own vite.config.ts hands back:
10
+ * `export default defineModConfig()`. Produces a single-file ESM bundle
11
+ * (`bundle.mjs`) with CSS inlined and injected at load, and the host's
12
+ * shared libraries externalized via fateSdkShims instead of bundled.
13
+ */
14
+ export declare function defineModConfig(overrides?: UserConfig): UserConfig;
package/dist/index.js ADDED
@@ -0,0 +1,57 @@
1
+ import vue from '@vitejs/plugin-vue';
2
+ import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
3
+ import { defineConfig } from 'vite';
4
+ import { fateSdkShims } from "./fateSdkShims.js";
5
+ import { manifestChecks } from "./manifestChecks.js";
6
+ /**
7
+ * Host-shared libraries a mod must not bundle its own copy of (decision D2).
8
+ * Keys are the import specifiers a mod author writes; values are the
9
+ * `globalThis.FateSDK` path the host installs before loading any mod.
10
+ */
11
+ export const EXTERNALS = {
12
+ vue: 'FateSDK.vue',
13
+ 'vue-i18n': 'FateSDK.vueI18n',
14
+ '@ionic/vue': 'FateSDK.ionicVue',
15
+ 'ionicons/icons': 'FateSDK.ionicons',
16
+ // Experimental (dice capability) — see docs/MOD_API.md. `Dice`/`DiceMaterial`
17
+ // themselves are NOT externalized: import them normally from
18
+ // @fate-app/mod-types, they bundle directly (see that package's dice.ts).
19
+ three: 'FateSDK.dice.three',
20
+ 'cannon-es': 'FateSDK.dice.cannonEs'
21
+ };
22
+ /**
23
+ * The Vite config a mod project's own vite.config.ts hands back:
24
+ * `export default defineModConfig()`. Produces a single-file ESM bundle
25
+ * (`bundle.mjs`) with CSS inlined and injected at load, and the host's
26
+ * shared libraries externalized via fateSdkShims instead of bundled.
27
+ */
28
+ export function defineModConfig(overrides = {}) {
29
+ return defineConfig({
30
+ plugins: [vue(), fateSdkShims(EXTERNALS), cssInjectedByJsPlugin(), manifestChecks()],
31
+ define: {
32
+ 'process.env.NODE_ENV': '"production"',
33
+ __VUE_OPTIONS_API__: 'true',
34
+ __VUE_PROD_DEVTOOLS__: 'false',
35
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
36
+ },
37
+ build: {
38
+ lib: {
39
+ entry: 'bundle.ts',
40
+ formats: ['es'],
41
+ fileName: () => 'bundle.mjs'
42
+ },
43
+ // Deliberately NOT `rollupOptions.external`: that tells the bundler to
44
+ // leave `import ... from 'vue'` as a literal, unresolvable bare
45
+ // specifier in the output (blob-URL `import()` has no bare-specifier
46
+ // resolution). fateSdkShims virtualizes these specifiers into local
47
+ // modules instead, which get bundled normally — the opposite of external.
48
+ rollupOptions: {
49
+ output: { codeSplitting: false }
50
+ },
51
+ cssCodeSplit: false,
52
+ assetsInlineLimit: 1024 * 1024,
53
+ emptyOutDir: true
54
+ },
55
+ ...overrides
56
+ });
57
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from 'vite';
2
+ /** Validates manifest.json presence/shape and warns/errors on bundle size (README.md §4 limits). */
3
+ export declare function manifestChecks(): Plugin;
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ const SOFT_LIMIT_BYTES = 1 * 1024 * 1024;
4
+ const HARD_LIMIT_BYTES = 3 * 1024 * 1024;
5
+ const RECOMMENDED_FIELDS = ['id', 'version', 'name', 'author', 'description', 'loadPriority'];
6
+ /** Validates manifest.json presence/shape and warns/errors on bundle size (README.md §4 limits). */
7
+ export function manifestChecks() {
8
+ return {
9
+ name: 'fate-mod-manifest-checks',
10
+ buildStart() {
11
+ const manifestPath = resolve(process.cwd(), 'manifest.json');
12
+ if (!existsSync(manifestPath)) {
13
+ this.error('manifest.json not found in the mod project root — every mod must ship one alongside its bundle.');
14
+ }
15
+ let manifest;
16
+ try {
17
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
18
+ }
19
+ catch (e) {
20
+ this.error(`manifest.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
21
+ return;
22
+ }
23
+ for (const field of RECOMMENDED_FIELDS) {
24
+ if (!(field in manifest)) {
25
+ this.warn(`manifest.json is missing recommended field "${field}"`);
26
+ }
27
+ }
28
+ },
29
+ writeBundle(_options, bundle) {
30
+ for (const [fileName, output] of Object.entries(bundle)) {
31
+ if (!fileName.endsWith('.mjs') && !fileName.endsWith('.js')) {
32
+ continue;
33
+ }
34
+ const size = 'code' in output ? Buffer.byteLength(output.code, 'utf-8') : 0;
35
+ if (size > HARD_LIMIT_BYTES) {
36
+ this.error(`${fileName} is ${(size / 1024 / 1024).toFixed(2)}MB, over the 3MB hard limit for a mod bundle.`);
37
+ }
38
+ else if (size > SOFT_LIMIT_BYTES) {
39
+ this.warn(`${fileName} is ${(size / 1024).toFixed(0)}KB, over the 1MB soft limit for a mod bundle — consider trimming assets.`);
40
+ }
41
+ }
42
+ }
43
+ };
44
+ }
@@ -0,0 +1 @@
1
+ export declare const SDK_EXPORTS: Record<string, string[]>;