@oss-idle/addon-sdk 0.0.0-stage → 0.6.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.
Files changed (70) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +262 -2
  3. package/bin/cli.mjs +137 -0
  4. package/bin/scaffold.mjs +157 -0
  5. package/dist/bridge/beatmaps.d.ts +57 -0
  6. package/dist/bridge/beatmaps.d.ts.map +1 -0
  7. package/dist/bridge/beatmaps.js +65 -0
  8. package/dist/bridge/character.d.ts +91 -0
  9. package/dist/bridge/character.d.ts.map +1 -0
  10. package/dist/bridge/character.js +107 -0
  11. package/dist/bridge/db.d.ts +44 -0
  12. package/dist/bridge/db.d.ts.map +1 -0
  13. package/dist/bridge/db.js +83 -0
  14. package/dist/bridge/playlists.d.ts +55 -0
  15. package/dist/bridge/playlists.d.ts.map +1 -0
  16. package/dist/bridge/playlists.js +85 -0
  17. package/dist/bridge/scores.d.ts +28 -0
  18. package/dist/bridge/scores.d.ts.map +1 -0
  19. package/dist/bridge/scores.js +43 -0
  20. package/dist/build/index.d.ts +73 -0
  21. package/dist/build/index.d.ts.map +1 -0
  22. package/dist/build/index.js +126 -0
  23. package/dist/build/plugins.d.ts +95 -0
  24. package/dist/build/plugins.d.ts.map +1 -0
  25. package/dist/build/plugins.js +240 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/react/index.d.ts +4 -0
  30. package/dist/react/index.d.ts.map +1 -0
  31. package/dist/react/index.js +3 -0
  32. package/dist/react/overlay.d.ts +56 -0
  33. package/dist/react/overlay.d.ts.map +1 -0
  34. package/dist/react/overlay.js +63 -0
  35. package/dist/react/reactAddon.d.ts +37 -0
  36. package/dist/react/reactAddon.d.ts.map +1 -0
  37. package/dist/react/reactAddon.js +51 -0
  38. package/dist/react/useSettings.d.ts +16 -0
  39. package/dist/react/useSettings.d.ts.map +1 -0
  40. package/dist/react/useSettings.js +30 -0
  41. package/dist/runtime/addon.d.ts +94 -0
  42. package/dist/runtime/addon.d.ts.map +1 -0
  43. package/dist/runtime/addon.js +129 -0
  44. package/dist/runtime/buildMeta.d.ts +11 -0
  45. package/dist/runtime/buildMeta.d.ts.map +1 -0
  46. package/dist/runtime/buildMeta.js +1 -0
  47. package/dist/runtime/define.d.ts +26 -0
  48. package/dist/runtime/define.d.ts.map +1 -0
  49. package/dist/runtime/define.js +40 -0
  50. package/dist/runtime/host.d.ts +113 -0
  51. package/dist/runtime/host.d.ts.map +1 -0
  52. package/dist/runtime/host.js +8 -0
  53. package/dist/runtime/input.d.ts +58 -0
  54. package/dist/runtime/input.d.ts.map +1 -0
  55. package/dist/runtime/input.js +119 -0
  56. package/dist/runtime/settings.d.ts +36 -0
  57. package/dist/runtime/settings.d.ts.map +1 -0
  58. package/dist/runtime/settings.js +74 -0
  59. package/package.json +83 -4
  60. package/templates/common/README.md +50 -0
  61. package/templates/common/addon.config.json +7 -0
  62. package/templates/common/eslint.config.js +54 -0
  63. package/templates/common/gitignore +3 -0
  64. package/templates/common/src/env.d.ts +17 -0
  65. package/templates/common/tsconfig.json +14 -0
  66. package/templates/plain/src/index.ts +32 -0
  67. package/templates/react/src/Addon.tsx +44 -0
  68. package/templates/react/src/Panel.tsx +41 -0
  69. package/templates/react/src/index.ts +12 -0
  70. package/templates/react/src/styles.css +61 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The add-on bundler.
3
+ *
4
+ * oss!idle stores an add-on as one `source` string and loads it with
5
+ * `import(URL.createObjectURL(new Blob([source])))`. A Blob module has no base
6
+ * URL and no import map, so **nothing** resolves at load time: React, the
7
+ * game's simulation, workers and CSS must all already be inside that one file,
8
+ * and it must be an ES module exporting `mount` and `unmount`.
9
+ *
10
+ * That is what this produces.
11
+ *
12
+ * Output is **not minified by default**. Published add-ons are read by a human
13
+ * moderator before they go live, and a single minified line is a poor thing to
14
+ * ask anyone to review.
15
+ */
16
+ import { build as esbuild } from 'esbuild';
17
+ import { createRequire } from 'node:module';
18
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { assetQueryPlugin, cssTextPlugin, dedupePlugin, hostReactPlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
21
+ /**
22
+ * The individual esbuild plugins, for a build that needs to assemble its own
23
+ * pipeline rather than call {@link bundleAddon} - compiling a slice of an add-on
24
+ * for a test, say, or bundling a worker on its own.
25
+ */
26
+ export { assetQueryPlugin, cssTextPlugin, dedupePlugin, hostReactPlugin, inlineWorkerPlugin, sharedSourcePlugin, stubModulePlugin, tsExtensionPlugin, } from './plugins.js';
27
+ const sharedSrcOf = (gameRoot) => resolve(gameRoot, 'packages/shared/src');
28
+ /** esbuild settings shared by the add-on bundle and any inlined worker. */
29
+ const baseOptions = (gameRoot, minify, stubModules = {}, dedupe = [], dedupeFrom = process.cwd(), plugins = []) => ({
30
+ bundle: true,
31
+ format: 'esm',
32
+ platform: 'browser',
33
+ target: 'es2022',
34
+ // The add-on runs inside the shipped client, which is a production React build.
35
+ define: { 'process.env.NODE_ENV': '"production"' },
36
+ jsx: 'automatic',
37
+ legalComments: 'inline',
38
+ // A Blob-loaded module cannot fetch a sibling asset, so a WebAssembly
39
+ // binary has to travel inside the bundle. Imported as base64, it is decoded
40
+ // at runtime and handed to the module's init as a BufferSource. Costs about
41
+ // a third more bytes than the binary itself.
42
+ loader: { '.wasm': 'base64' },
43
+ minify,
44
+ plugins: [
45
+ ...plugins,
46
+ // Strip asset suffixes before anything tries to resolve them as modules.
47
+ assetQueryPlugin(),
48
+ // Dedupe first: it must see a bare package name before anything else
49
+ // resolves it out of the game's checkout.
50
+ dedupePlugin(dedupe, dedupeFrom),
51
+ ...(gameRoot ? [sharedSourcePlugin(sharedSrcOf(gameRoot))] : []),
52
+ tsExtensionPlugin(),
53
+ stubModulePlugin(stubModules),
54
+ cssTextPlugin(),
55
+ ],
56
+ });
57
+ /** Build one worker entry to a self-contained string for inlining. */
58
+ const buildWorkerSource = async (entry, gameRoot, minify, stubModules, dedupe, dedupeFrom, plugins) => {
59
+ const result = await esbuild({
60
+ ...baseOptions(gameRoot, minify, stubModules, dedupe, dedupeFrom, plugins),
61
+ entryPoints: [entry],
62
+ write: false,
63
+ // A worker built as ESM would need `type: 'module'` support *and* would
64
+ // keep top-level await; IIFE keeps the inlined string self-contained.
65
+ format: 'iife',
66
+ });
67
+ return result.outputFiles?.[0]?.text ?? '';
68
+ };
69
+ /**
70
+ * This SDK's own version, for the banner and the stamped meta - the two places
71
+ * a reader asks what built a bundle they are holding.
72
+ */
73
+ const sdkVersion = createRequire(import.meta.url)('@oss-idle/addon-sdk/package.json').version;
74
+ /** Bundle an add-on into the single ES module the runtime loads. */
75
+ export const bundleAddon = async (options) => {
76
+ const { entry, outfile, gameRoot, meta, minify = false, sourcemap = false, stubModules = {}, dedupe = [], dedupeFrom = dirname(resolve(entry)), plugins = [], sharedReact = false, } = options;
77
+ // The SDK's React modules are imported from wherever npm put the SDK, so
78
+ // `react` resolves from there unless it is pointed back at the add-on that
79
+ // installed it - and two Reacts in one bundle break hooks outright. With the
80
+ // game's React there is none to bundle.
81
+ const names = [...new Set([...sharedReact ? [] : ['react', 'react-dom'], ...dedupe])];
82
+ const ownPlugins = sharedReact ? [hostReactPlugin(dedupeFrom), ...plugins] : plugins;
83
+ const base = baseOptions(gameRoot, minify, stubModules, names, dedupeFrom, ownPlugins);
84
+ const result = await esbuild({
85
+ ...base,
86
+ // Read back at runtime by `buildMeta()`.
87
+ define: { ...base.define, __OSS_IDLE_ADDON__: JSON.stringify({ ...meta, sdkVersion }) },
88
+ entryPoints: [entry],
89
+ write: false,
90
+ sourcemap: sourcemap ? 'inline' : false,
91
+ plugins: [
92
+ inlineWorkerPlugin(workerEntry => buildWorkerSource(workerEntry, gameRoot, minify, stubModules, dedupe, dedupeFrom, plugins)),
93
+ ...(base.plugins ?? []),
94
+ ],
95
+ });
96
+ const code = result.outputFiles?.[0]?.text ?? '';
97
+ // The trailing blank line is not optional: without it the last banner comment
98
+ // would swallow the first line of generated code.
99
+ const banner = meta
100
+ ? [
101
+ '// ' + [meta.name, meta.version && `v${meta.version}`].filter(Boolean).join(' '),
102
+ meta.gameVersion && `// built against oss!idle ${meta.gameVersion}`,
103
+ meta.license && `// ${meta.license}`,
104
+ `// Bundled with @oss-idle/addon-sdk v${sdkVersion}. Paste this file into the add-on editor.`,
105
+ ].filter(Boolean).join('\n') + '\n\n'
106
+ : '';
107
+ const source = banner + code;
108
+ await mkdir(dirname(resolve(outfile)), { recursive: true });
109
+ await writeFile(outfile, source, 'utf8');
110
+ await assertAddonShape(outfile, source);
111
+ return { outfile, bytes: Buffer.byteLength(source, 'utf8') };
112
+ };
113
+ /**
114
+ * Fail the build if the output would not satisfy the runtime contract.
115
+ *
116
+ * A bundle missing `mount` loads without error and simply does nothing, which
117
+ * is a miserable thing to debug from inside the game - much better to catch it
118
+ * here.
119
+ */
120
+ const assertAddonShape = async (outfile, source) => {
121
+ const code = source || await readFile(outfile, 'utf8');
122
+ if (!/export\s*\{[^}]*\bmount\b/.test(code) && !/export\s+(const|function)\s+mount\b/.test(code)) {
123
+ throw new Error(`${outfile} does not export "mount" - the add-on runtime would load it and call nothing. ` +
124
+ 'Export it from your entry, e.g. `export const { mount, unmount } = defineAddon(...)`.');
125
+ }
126
+ };
@@ -0,0 +1,95 @@
1
+ import type { Plugin } from 'esbuild';
2
+ /**
3
+ * Resolve `@oss-idle/shared/*` against a checkout of the game.
4
+ *
5
+ * The package is `private: true` and never published, so there is nothing to
6
+ * install - the add-on compiles the game's TypeScript sources into its own
7
+ * bundle instead. Its `exports` map points at `dist/`, which a plain checkout
8
+ * has not built, so this resolves to `src/` directly.
9
+ *
10
+ * The sources are written for NodeNext, where a relative import of a TypeScript
11
+ * file spells its *output* extension (`'../skills.js'` for `skills.ts`). esbuild
12
+ * takes that literally and would fail to find the file, so both this plugin and
13
+ * {@link tsExtensionPlugin} map `.js` back onto the `.ts` that produced it.
14
+ */
15
+ export declare const sharedSourcePlugin: (sharedSrc: string) => Plugin;
16
+ /**
17
+ * Map a relative `./x.js` import onto the `./x.ts` beside it.
18
+ *
19
+ * Only applies when the `.js` file does not exist and a `.ts` does, so a
20
+ * genuine JavaScript dependency is never hijacked.
21
+ */
22
+ export declare const tsExtensionPlugin: () => Plugin;
23
+ /**
24
+ * `import worker from 'worker:./sim.worker'` - build that entry as its own
25
+ * bundle and inline it as a source string, plus a factory that spawns it from a
26
+ * Blob URL.
27
+ *
28
+ * An add-on is itself loaded from a Blob, so it has no URL to resolve a worker
29
+ * file against; the worker has to travel inside the same file. Workers matter
30
+ * here because the game is a rhythm game sharing this thread - a simulation
31
+ * sweep run on the main thread would stutter gameplay.
32
+ */
33
+ export declare const inlineWorkerPlugin: (buildWorker: (entry: string) => Promise<string>) => Plugin;
34
+ /** Load `.css` imports as strings, for injection into the overlay's shadow root. */
35
+ export declare const cssTextPlugin: () => Plugin;
36
+ /**
37
+ * Replace a module the add-on never actually calls.
38
+ *
39
+ * The game's modules are written for an app that ships everything, so a small
40
+ * helper can sit in a file that also pulls in something large - importing
41
+ * `hasUnlock` from `shared/rebirth` drags in the whole of zod, half a megabyte,
42
+ * for three lines of logic that never touch it. esbuild cannot drop that on its
43
+ * own: building a zod schema at module scope is a side effect, so the import is
44
+ * kept even though nothing uses its exports.
45
+ *
46
+ * Stubbing lets the add-on keep importing the game's real helper - so the logic
47
+ * stays the game's, not a copy - while the unused dependency is left out.
48
+ *
49
+ * The stub **throws** if anything ever reaches it, so a wrong guess about what
50
+ * is unused fails loudly at the call instead of silently misbehaving.
51
+ *
52
+ * `stubs` maps a path suffix (e.g. `shared/src/onboarding.ts`) to the names to
53
+ * export from the replacement.
54
+ */
55
+ export declare const stubModulePlugin: (stubs: Record<string, readonly string[]>) => Plugin;
56
+ /**
57
+ * Force a package to resolve to one copy.
58
+ *
59
+ * An add-on bundles its own sources *and* the game's, which live in a different
60
+ * checkout with its own `node_modules`. esbuild resolves each import from the
61
+ * importing file, so a package both sides use - `osu-classes`, say - gets
62
+ * bundled **twice**: the game's sim ends up holding different class objects from
63
+ * the ones the add-on's decoder produces.
64
+ *
65
+ * That is not just wasted bytes. Prototype patches apply to one copy while the
66
+ * other is the one actually used (the game's control-point decode patch fails
67
+ * exactly this way), and `instanceof` across the boundary silently returns
68
+ * false.
69
+ *
70
+ * Resolving these names from a single directory collapses them to one copy.
71
+ * The versions must be compatible, which is why the add-on pins the same ones
72
+ * the game does.
73
+ */
74
+ export declare const dedupePlugin: (names: readonly string[], fromDir: string) => Plugin;
75
+ /**
76
+ * Explicit asset imports: `import bytes from './thing.wasm?base64'`.
77
+ *
78
+ * The suffix does two jobs. It says at the import site how the file arrives -
79
+ * mirroring the `?url` convention the client uses under Vite - and it keeps
80
+ * TypeScript off a shipped `.wasm.d.ts`, which would otherwise win over the
81
+ * add-on's own `declare module` and report the import as having no default
82
+ * export.
83
+ *
84
+ * Resolution strips the suffix, so esbuild's loader for the real extension
85
+ * (`.wasm` -> base64) applies as normal.
86
+ */
87
+ export declare const assetQueryPlugin: () => Plugin;
88
+ /**
89
+ * React from the game instead of a bundled copy: each React import becomes a small
90
+ * module reading the game's own React off `globalThis.ossIdle.react`, which the game
91
+ * sets before it loads any add-on. Its named exports are the installed React's, read
92
+ * here at build time, so `import { useState } from 'react'` keeps working.
93
+ */
94
+ export declare const hostReactPlugin: (fromDir: string) => Plugin;
95
+ //# sourceMappingURL=plugins.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugins.d.ts","sourceRoot":"","sources":["../../src/build/plugins.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,GAAI,WAAW,MAAM,KAAG,MAgBrD,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,QAAO,MAanC,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAAI,aAAa,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,KAAG,MA0BnF,CAAC;AAEH,oFAAoF;AACpF,eAAO,MAAM,aAAa,QAAO,MAQ/B,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,KAAG,MAsB1E,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,SAAS,MAAM,EAAE,EAAE,SAAS,MAAM,KAAG,MAmBvE,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,QAAO,MAgBlC,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,KAAG,MAmBhD,CAAC"}
@@ -0,0 +1,240 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, resolve } from 'node:path';
5
+ /**
6
+ * Resolve `@oss-idle/shared/*` against a checkout of the game.
7
+ *
8
+ * The package is `private: true` and never published, so there is nothing to
9
+ * install - the add-on compiles the game's TypeScript sources into its own
10
+ * bundle instead. Its `exports` map points at `dist/`, which a plain checkout
11
+ * has not built, so this resolves to `src/` directly.
12
+ *
13
+ * The sources are written for NodeNext, where a relative import of a TypeScript
14
+ * file spells its *output* extension (`'../skills.js'` for `skills.ts`). esbuild
15
+ * takes that literally and would fail to find the file, so both this plugin and
16
+ * {@link tsExtensionPlugin} map `.js` back onto the `.ts` that produced it.
17
+ */
18
+ export const sharedSourcePlugin = (sharedSrc) => ({
19
+ name: 'oss-idle-shared-source',
20
+ setup(build) {
21
+ build.onResolve({ filter: /^@oss-idle\/shared(\/.*)?$/ }, args => {
22
+ const subpath = args.path.replace(/^@oss-idle\/shared\/?/, '') || 'index';
23
+ const base = resolve(sharedSrc, subpath.replace(/\.js$/, ''));
24
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, resolve(base, 'index.ts')]) {
25
+ if (existsSync(candidate))
26
+ return { path: candidate };
27
+ }
28
+ return {
29
+ errors: [{
30
+ text: `Cannot resolve "${args.path}" in the oss!idle checkout (looked under ${sharedSrc})`,
31
+ }],
32
+ };
33
+ });
34
+ },
35
+ });
36
+ /**
37
+ * Map a relative `./x.js` import onto the `./x.ts` beside it.
38
+ *
39
+ * Only applies when the `.js` file does not exist and a `.ts` does, so a
40
+ * genuine JavaScript dependency is never hijacked.
41
+ */
42
+ export const tsExtensionPlugin = () => ({
43
+ name: 'ts-extension',
44
+ setup(build) {
45
+ build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, args => {
46
+ if (!args.importer.match(/\.tsx?$/))
47
+ return undefined;
48
+ const js = resolve(dirname(args.importer), args.path);
49
+ if (existsSync(js))
50
+ return undefined;
51
+ for (const candidate of [js.replace(/\.js$/, '.ts'), js.replace(/\.js$/, '.tsx')]) {
52
+ if (existsSync(candidate))
53
+ return { path: candidate };
54
+ }
55
+ return undefined;
56
+ });
57
+ },
58
+ });
59
+ /**
60
+ * `import worker from 'worker:./sim.worker'` - build that entry as its own
61
+ * bundle and inline it as a source string, plus a factory that spawns it from a
62
+ * Blob URL.
63
+ *
64
+ * An add-on is itself loaded from a Blob, so it has no URL to resolve a worker
65
+ * file against; the worker has to travel inside the same file. Workers matter
66
+ * here because the game is a rhythm game sharing this thread - a simulation
67
+ * sweep run on the main thread would stutter gameplay.
68
+ */
69
+ export const inlineWorkerPlugin = (buildWorker) => ({
70
+ name: 'inline-worker',
71
+ setup(build) {
72
+ build.onResolve({ filter: /^worker:/ }, args => {
73
+ const target = args.path.slice('worker:'.length);
74
+ const base = resolve(dirname(args.importer), target);
75
+ for (const candidate of [base, `${base}.ts`, `${base}.js`]) {
76
+ if (existsSync(candidate))
77
+ return { path: candidate, namespace: 'inline-worker' };
78
+ }
79
+ return { errors: [{ text: `Worker entry not found: ${args.path}` }] };
80
+ });
81
+ build.onLoad({ filter: /.*/, namespace: 'inline-worker' }, async (args) => ({
82
+ contents: `
83
+ const source = ${JSON.stringify(await buildWorker(args.path))};
84
+ let url;
85
+ /** Spawn the inlined worker. The Blob URL is created once and reused. */
86
+ export default function createWorker() {
87
+ url ??= URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
88
+ return new Worker(url, { type: 'module' });
89
+ }
90
+ `,
91
+ loader: 'js',
92
+ resolveDir: dirname(args.path),
93
+ }));
94
+ },
95
+ });
96
+ /** Load `.css` imports as strings, for injection into the overlay's shadow root. */
97
+ export const cssTextPlugin = () => ({
98
+ name: 'css-text',
99
+ setup(build) {
100
+ build.onLoad({ filter: /\.css$/ }, async (args) => ({
101
+ contents: `export default ${JSON.stringify(await readFile(args.path, 'utf8'))};`,
102
+ loader: 'js',
103
+ }));
104
+ },
105
+ });
106
+ /**
107
+ * Replace a module the add-on never actually calls.
108
+ *
109
+ * The game's modules are written for an app that ships everything, so a small
110
+ * helper can sit in a file that also pulls in something large - importing
111
+ * `hasUnlock` from `shared/rebirth` drags in the whole of zod, half a megabyte,
112
+ * for three lines of logic that never touch it. esbuild cannot drop that on its
113
+ * own: building a zod schema at module scope is a side effect, so the import is
114
+ * kept even though nothing uses its exports.
115
+ *
116
+ * Stubbing lets the add-on keep importing the game's real helper - so the logic
117
+ * stays the game's, not a copy - while the unused dependency is left out.
118
+ *
119
+ * The stub **throws** if anything ever reaches it, so a wrong guess about what
120
+ * is unused fails loudly at the call instead of silently misbehaving.
121
+ *
122
+ * `stubs` maps a path suffix (e.g. `shared/src/onboarding.ts`) to the names to
123
+ * export from the replacement.
124
+ */
125
+ export const stubModulePlugin = (stubs) => ({
126
+ name: 'stub-module',
127
+ setup(build) {
128
+ const suffixes = Object.keys(stubs);
129
+ if (!suffixes.length)
130
+ return;
131
+ build.onLoad({ filter: /\.tsx?$/ }, args => {
132
+ const normalized = args.path.split('\\').join('/');
133
+ const match = suffixes.find(suffix => normalized.endsWith(suffix));
134
+ if (!match)
135
+ return undefined;
136
+ const names = stubs[match] ?? [];
137
+ const contents = names.map(name => `export const ${name} = new Proxy(function () {}, {\n` +
138
+ ` get() { throw new Error('${name} was stubbed out of this add-on bundle but is being used'); },\n` +
139
+ ` apply() { throw new Error('${name} was stubbed out of this add-on bundle but is being called'); },\n` +
140
+ '});').join('\n');
141
+ return { contents, loader: 'js' };
142
+ });
143
+ },
144
+ });
145
+ /**
146
+ * Force a package to resolve to one copy.
147
+ *
148
+ * An add-on bundles its own sources *and* the game's, which live in a different
149
+ * checkout with its own `node_modules`. esbuild resolves each import from the
150
+ * importing file, so a package both sides use - `osu-classes`, say - gets
151
+ * bundled **twice**: the game's sim ends up holding different class objects from
152
+ * the ones the add-on's decoder produces.
153
+ *
154
+ * That is not just wasted bytes. Prototype patches apply to one copy while the
155
+ * other is the one actually used (the game's control-point decode patch fails
156
+ * exactly this way), and `instanceof` across the boundary silently returns
157
+ * false.
158
+ *
159
+ * Resolving these names from a single directory collapses them to one copy.
160
+ * The versions must be compatible, which is why the add-on pins the same ones
161
+ * the game does.
162
+ */
163
+ export const dedupePlugin = (names, fromDir) => ({
164
+ name: 'dedupe',
165
+ setup(build) {
166
+ if (!names.length)
167
+ return;
168
+ const pattern = new RegExp(`^(${names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(\\/.*)?$`);
169
+ build.onResolve({ filter: pattern }, async (args) => {
170
+ // Skip our own re-resolution, or we recurse forever.
171
+ if (args.pluginData === 'dedupe')
172
+ return undefined;
173
+ const result = await build.resolve(args.path, {
174
+ kind: args.kind,
175
+ resolveDir: fromDir,
176
+ pluginData: 'dedupe',
177
+ });
178
+ if (result.errors.length)
179
+ return undefined;
180
+ return { path: result.path, external: result.external };
181
+ });
182
+ },
183
+ });
184
+ /**
185
+ * Explicit asset imports: `import bytes from './thing.wasm?base64'`.
186
+ *
187
+ * The suffix does two jobs. It says at the import site how the file arrives -
188
+ * mirroring the `?url` convention the client uses under Vite - and it keeps
189
+ * TypeScript off a shipped `.wasm.d.ts`, which would otherwise win over the
190
+ * add-on's own `declare module` and report the import as having no default
191
+ * export.
192
+ *
193
+ * Resolution strips the suffix, so esbuild's loader for the real extension
194
+ * (`.wasm` -> base64) applies as normal.
195
+ */
196
+ export const assetQueryPlugin = () => ({
197
+ name: 'asset-query',
198
+ setup(build) {
199
+ build.onResolve({ filter: /\?(base64|text)$/ }, async (args) => {
200
+ if (args.pluginData === 'asset-query')
201
+ return undefined;
202
+ const path = args.path.replace(/\?(base64|text)$/, '');
203
+ const result = await build.resolve(path, {
204
+ kind: args.kind,
205
+ resolveDir: args.resolveDir,
206
+ importer: args.importer,
207
+ pluginData: 'asset-query',
208
+ });
209
+ if (result.errors.length)
210
+ return { errors: result.errors };
211
+ return { path: result.path, external: result.external };
212
+ });
213
+ },
214
+ });
215
+ /**
216
+ * React from the game instead of a bundled copy: each React import becomes a small
217
+ * module reading the game's own React off `globalThis.ossIdle.react`, which the game
218
+ * sets before it loads any add-on. Its named exports are the installed React's, read
219
+ * here at build time, so `import { useState } from 'react'` keeps working.
220
+ */
221
+ export const hostReactPlugin = (fromDir) => ({
222
+ name: 'host-react',
223
+ setup(build) {
224
+ const requireFrom = createRequire(resolve(fromDir, 'package.json'));
225
+ build.onResolve({ filter: /^react(-dom)?(\/(client|jsx-runtime))?$/ }, args => ({
226
+ path: args.path, namespace: 'oss-idle-react',
227
+ }));
228
+ build.onLoad({ filter: /.*/, namespace: 'oss-idle-react' }, args => {
229
+ const names = Object.keys(requireFrom(args.path))
230
+ .filter(name => name !== 'default' && /^[A-Za-z_$][\w$]*$/.test(name));
231
+ const contents = [
232
+ `const m = globalThis.ossIdle?.react?.[${JSON.stringify(args.path)}];`,
233
+ 'if (!m) throw new Error("This add-on uses the game\'s React: update oss!idle to run it");',
234
+ 'export default m;',
235
+ ...names.map(name => `export const ${name} = m.${name};`),
236
+ ].join('\n');
237
+ return { contents, loader: 'js' };
238
+ });
239
+ },
240
+ });
@@ -0,0 +1,12 @@
1
+ export { Addon, type AddonMeta, type Disposer } from './runtime/addon.js';
2
+ export { defineAddon, type AddonModule } from './runtime/define.js';
3
+ export { gameHost, type AddonHost, type EditorApi, type EditorMetadata, type EditorNote, type EditorRender, type EditorTimingPoint, } from './runtime/host.js';
4
+ export { buildMeta, type BuildMeta } from './runtime/buildMeta.js';
5
+ export { HotkeyManager, isolateInput, parseHotkey, type Hotkey, } from './runtime/input.js';
6
+ export { createSettings, type SettingsStore } from './runtime/settings.js';
7
+ export { gameDb, type SqlValue } from './bridge/db.js';
8
+ export { beatmapStore, type StoredSetMeta, type StoredVersion, } from './bridge/beatmaps.js';
9
+ export { gameCharacter, type CharacterRow } from './bridge/character.js';
10
+ export { playlists, type PlaylistRow } from './bridge/playlists.js';
11
+ export { playCountsSince, recentPlayCounts } from './bridge/scores.js';
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACpE,OAAO,EACN,QAAQ,EACR,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,iBAAiB,GACtB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnE,OAAO,EACN,aAAa,EACb,YAAY,EACZ,WAAW,EACX,KAAK,MAAM,GACX,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3E,OAAO,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EACN,YAAY,EACZ,KAAK,aAAa,EAClB,KAAK,aAAa,GAClB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { Addon } from './runtime/addon.js';
2
+ export { defineAddon } from './runtime/define.js';
3
+ export { gameHost, } from './runtime/host.js';
4
+ export { buildMeta } from './runtime/buildMeta.js';
5
+ export { HotkeyManager, isolateInput, parseHotkey, } from './runtime/input.js';
6
+ export { createSettings } from './runtime/settings.js';
7
+ export { gameDb } from './bridge/db.js';
8
+ export { beatmapStore, } from './bridge/beatmaps.js';
9
+ export { gameCharacter } from './bridge/character.js';
10
+ export { playlists } from './bridge/playlists.js';
11
+ export { playCountsSince, recentPlayCounts } from './bridge/scores.js';
@@ -0,0 +1,4 @@
1
+ export { createOverlay, OVERLAY_Z_INDEX, type Overlay, type OverlayOptions, } from './overlay.js';
2
+ export { ReactAddon, type ReactAddonMeta } from './reactAddon.js';
3
+ export { usePersistedSettings, type SettingsHandle } from './useSettings.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,aAAa,EACb,eAAe,EACf,KAAK,OAAO,EACZ,KAAK,cAAc,GACnB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAClE,OAAO,EAAE,oBAAoB,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { createOverlay, OVERLAY_Z_INDEX, } from './overlay.js';
2
+ export { ReactAddon } from './reactAddon.js';
3
+ export { usePersistedSettings } from './useSettings.js';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The overlay host.
3
+ *
4
+ * An add-on shares one document with the whole game, so a naive `<div>` on
5
+ * `document.body` inherits the client's styles and leaks its own back. The host
6
+ * therefore attaches a **shadow root** and renders inside it: the game's CSS
7
+ * stops at the boundary, and the add-on's stylesheet cannot restyle the game.
8
+ *
9
+ * Input is isolated at the host element, where events from inside the shadow
10
+ * tree bubble through - see `runtime/input.ts` for why bubble phase is the
11
+ * right place for that.
12
+ */
13
+ import type { Disposer } from '../runtime/addon.js';
14
+ /**
15
+ * Default stacking order: above every piece of game UI, below the cursor.
16
+ *
17
+ * The client draws its own osu! cursor as a DOM element (`.cursor`, z-index
18
+ * 100000, with `.cursor-trail` at 99999) and hides the system one with
19
+ * `html.custom-cursor * { cursor: none !important }`. An overlay stacked above
20
+ * that layer breaks the pointer twice over: it covers the drawn cursor, and -
21
+ * because `cursor` is an inherited property - the `cursor: none` on the host
22
+ * inherits through the shadow boundary, so the system cursor stays hidden too.
23
+ * The player is left with no pointer at all, flickering into view only over
24
+ * translucent parts of the overlay.
25
+ *
26
+ * Sitting below the cursor layer instead means the osu! cursor draws over the
27
+ * overlay exactly as it does over the rest of the game. The game's own UI tops
28
+ * out around 20000, so this clears it comfortably.
29
+ */
30
+ export declare const OVERLAY_Z_INDEX = 50000;
31
+ export type OverlayOptions = {
32
+ /** Element id, so a stale host from a failed unmount can be reclaimed. */
33
+ id: string;
34
+ /** CSS for the shadow tree. The bundler inlines `.css` imports into a string. */
35
+ styles?: string;
36
+ /**
37
+ * Stacking order against the game's own layers. Defaults to
38
+ * {@link OVERLAY_Z_INDEX}; raising it above 99999 will hide the player's
39
+ * cursor.
40
+ */
41
+ zIndex?: number;
42
+ };
43
+ export type Overlay = {
44
+ /** The element in the page. Input isolation is bound here. */
45
+ host: HTMLElement;
46
+ /** The shadow root React renders into. */
47
+ container: HTMLElement;
48
+ dispose: Disposer;
49
+ };
50
+ /**
51
+ * Create the overlay host. Idempotent by `id`: a host left behind by a crashed
52
+ * unmount is removed rather than duplicated, so enable/disable cycles cannot
53
+ * stack overlays.
54
+ */
55
+ export declare const createOverlay: ({ id, styles, zIndex }: OverlayOptions) => Overlay;
56
+ //# sourceMappingURL=overlay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"overlay.d.ts","sourceRoot":"","sources":["../../src/react/overlay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAGpD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,QAAS,CAAC;AAEtC,MAAM,MAAM,cAAc,GAAG;IAC5B,0EAA0E;IAC1E,EAAE,EAAE,MAAM,CAAC;IACX,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IACrB,8DAA8D;IAC9D,IAAI,EAAE,WAAW,CAAC;IAClB,0CAA0C;IAC1C,SAAS,EAAE,WAAW,CAAC;IACvB,OAAO,EAAE,QAAQ,CAAC;CAClB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,wBAA0C,cAAc,KAAG,OAkCxF,CAAC"}
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The overlay host.
3
+ *
4
+ * An add-on shares one document with the whole game, so a naive `<div>` on
5
+ * `document.body` inherits the client's styles and leaks its own back. The host
6
+ * therefore attaches a **shadow root** and renders inside it: the game's CSS
7
+ * stops at the boundary, and the add-on's stylesheet cannot restyle the game.
8
+ *
9
+ * Input is isolated at the host element, where events from inside the shadow
10
+ * tree bubble through - see `runtime/input.ts` for why bubble phase is the
11
+ * right place for that.
12
+ */
13
+ import { isolateInput } from '../runtime/input.js';
14
+ /**
15
+ * Default stacking order: above every piece of game UI, below the cursor.
16
+ *
17
+ * The client draws its own osu! cursor as a DOM element (`.cursor`, z-index
18
+ * 100000, with `.cursor-trail` at 99999) and hides the system one with
19
+ * `html.custom-cursor * { cursor: none !important }`. An overlay stacked above
20
+ * that layer breaks the pointer twice over: it covers the drawn cursor, and -
21
+ * because `cursor` is an inherited property - the `cursor: none` on the host
22
+ * inherits through the shadow boundary, so the system cursor stays hidden too.
23
+ * The player is left with no pointer at all, flickering into view only over
24
+ * translucent parts of the overlay.
25
+ *
26
+ * Sitting below the cursor layer instead means the osu! cursor draws over the
27
+ * overlay exactly as it does over the rest of the game. The game's own UI tops
28
+ * out around 20000, so this clears it comfortably.
29
+ */
30
+ export const OVERLAY_Z_INDEX = 50_000;
31
+ /**
32
+ * Create the overlay host. Idempotent by `id`: a host left behind by a crashed
33
+ * unmount is removed rather than duplicated, so enable/disable cycles cannot
34
+ * stack overlays.
35
+ */
36
+ export const createOverlay = ({ id, styles, zIndex = OVERLAY_Z_INDEX }) => {
37
+ document.getElementById(id)?.remove();
38
+ const host = document.createElement('div');
39
+ host.id = id;
40
+ // `pointer-events: none` keeps the (full-screen) host from swallowing clicks
41
+ // meant for the game; the rendered panels turn it back on for themselves.
42
+ host.style.cssText =
43
+ `position:fixed;inset:0;z-index:${zIndex};pointer-events:none;`;
44
+ const shadow = host.attachShadow({ mode: 'open' });
45
+ if (styles) {
46
+ const sheet = document.createElement('style');
47
+ sheet.textContent = styles;
48
+ shadow.append(sheet);
49
+ }
50
+ const container = document.createElement('div');
51
+ container.style.cssText = 'position:absolute;inset:0;pointer-events:none;';
52
+ shadow.append(container);
53
+ document.body.append(host);
54
+ const releaseInput = isolateInput(host);
55
+ return {
56
+ host,
57
+ container,
58
+ dispose: () => {
59
+ releaseInput();
60
+ host.remove();
61
+ },
62
+ };
63
+ };