@spicetify/kit 0.4.3 → 0.4.4

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/dist/push.js CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import path from "node:path";
15
+ import { KIND_OF_META_SOURCE } from "./vault-metadata.js";
15
16
  // localStorage quota is ~5 MB for the xpui origin, shared with Spotify's own
16
17
  // keys and other local modules, so thresholds are conservative. Counted in
17
18
  // UTF-16 code units (JSON.stringify(...).length), the unit the quota counts.
@@ -257,8 +258,8 @@ export function pushExpression(rec, id, nonce, stamped) {
257
258
  if (reenabled) await (M.reload ?? M.enable)(id);
258
259
  // Re-enabling a theme the loader just unloaded would fight the
259
260
  // single-active-theme invariant and knock the pushed theme back off.
260
- // metadata.json says "kind"; modules published before it carry "tags".
261
- const themed = (meta) => meta?.kind === "theme" || (meta?.tags ?? []).includes("theme");
261
+ const kindOf = ${KIND_OF_META_SOURCE};
262
+ const themed = (meta) => kindOf(meta) === "theme";
262
263
  const pushedIsTheme = themed(rec.metadata);
263
264
  const isTheme = (mid) => themed(M.manifest?.modules?.find((m) => m.identifier === mid));
264
265
  for (const other of before) {
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { readdirSync, readFileSync, statSync } from "node:fs";
6
6
  import path from "node:path";
7
+ import { kindOfMeta } from "./vault-metadata.js";
7
8
  export const STDLIB_BOUNDARY_WARNING_RULES = ["ambient-client", "client-dom", "direct-map"];
8
9
  const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx"]);
9
10
  const STYLE_EXTENSIONS = new Set([".css", ".scss"]);
@@ -202,7 +203,7 @@ export function checkExternalStdlibBoundary(root, metadata) {
202
203
  const entries = meta.entries;
203
204
  if (typeof entries?.js !== "string" || meta.name === "stdlib")
204
205
  return out;
205
- const isTheme = meta.kind === "theme" || (Array.isArray(meta.tags) && meta.tags.some((tag) => tag === "theme"));
206
+ const isTheme = kindOfMeta(meta) === "theme";
206
207
  const dependencies = meta.dependencies;
207
208
  const missingDependency = !isTheme &&
208
209
  typeof dependencies === "object" &&
@@ -12,6 +12,14 @@ export const kindOfMeta = (meta) => {
12
12
  const tags = Array.isArray(meta.tags) ? meta.tags : [];
13
13
  return KINDS.find((kind) => tags.includes(kind));
14
14
  };
15
+ // kindOfMeta as JavaScript source, for expressions evaluated inside the
16
+ // client where kit code cannot be imported. It also accepts a missing meta.
17
+ export const KIND_OF_META_SOURCE = `((meta) => {
18
+ const kinds = ${JSON.stringify(KINDS)};
19
+ if (typeof meta?.kind === "string" && kinds.includes(meta.kind)) return meta.kind;
20
+ const tags = Array.isArray(meta?.tags) ? meta.tags : [];
21
+ return kinds.find((kind) => tags.includes(kind));
22
+ })`;
15
23
  // metadata.json authors are plain names; author objects (with a github)
16
24
  // pass through, so an artifact may declare either.
17
25
  const normalizeAuthors = (authors) => authors.flatMap((a) => {
package/dist/version.js CHANGED
@@ -4,5 +4,5 @@
4
4
  */
5
5
  // Kept in lockstep with package.json by release-please. Scaffolds must depend
6
6
  // on the kit version that generated them, especially across pre-1.0 minors.
7
- export const KIT_VERSION = "0.4.3"; // x-release-please-version
7
+ export const KIT_VERSION = "0.4.4"; // x-release-please-version
8
8
  export const KIT_DEPENDENCY_RANGE = `^${KIT_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spicetify/kit",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Developer kit for spicetify v3 modules: scaffold, build, hot-push, and pack",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://github.com/spicetify/modules#readme",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "spicetify": {
43
- "stdlibVersion": "1.13.1"
43
+ "stdlibVersion": "1.13.2"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.build.json",
@@ -3,7 +3,7 @@
3
3
  "kind": "lib",
4
4
  "hidden": true,
5
5
  "preview": "./assets/PREVIEW.png",
6
- "version": "1.13.1",
6
+ "version": "1.13.2",
7
7
  "compat": ["0.3.0"],
8
8
  "authors": ["spicetify", "Delusoire"],
9
9
  "description": "The standard library",
@@ -14,9 +14,10 @@ type Predicate<A> = (value: A) => boolean;
14
14
 
15
15
  // Some client exports are functions whose own toString is not callable;
16
16
  // they can never match a needle, so they stringify to "".
17
- const safeString = (x: any): string => {
17
+ const stringify = (x: any): string => {
18
+ if (x == null) return "";
18
19
  try {
19
- return x.toString();
20
+ return String(x);
20
21
  } catch {
21
22
  try {
22
23
  return Function.prototype.toString.call(x);
@@ -26,18 +27,32 @@ const safeString = (x: any): string => {
26
27
  }
27
28
  };
28
29
 
30
+ const sources = new WeakMap<object, string>();
31
+
32
+ // sourceOf is the source text needles match against, computed once per
33
+ // function or object for the whole session.
34
+ export const sourceOf = (x: unknown): string => {
35
+ if ((typeof x !== "object" && typeof x !== "function") || x === null) return stringify(x);
36
+ let source = sources.get(x);
37
+ if (source === undefined) {
38
+ source = stringify(x);
39
+ sources.set(x, source);
40
+ }
41
+ return source;
42
+ };
43
+
29
44
  export function findBy(...tests: Array<string | RegExp | Predicate<any>>) {
30
45
  const testFns = tests.map((test): Predicate<any> => {
31
46
  switch (typeof test) {
32
47
  case "string":
33
- return (x) => safeString(x).includes(test);
48
+ return (x) => sourceOf(x).includes(test);
34
49
  case "function":
35
50
  return (x) => test(x);
36
51
  default: // assume regex
37
- return (x) => test.test(safeString(x));
52
+ return (x) => test.test(sourceOf(x));
38
53
  }
39
54
  });
40
- const testFn = (x: any) => testFns.map((t) => t(x)).every(Boolean);
55
+ const testFn = (x: any) => testFns.every((t) => t(x));
41
56
  return <A>(xs: A[]) => xs.find(testFn)!;
42
57
  }
43
58
 
@@ -10,9 +10,19 @@ await CHUNKS.xpui.promise;
10
10
  const componentNames = Object.keys(
11
11
  exported.find((e) => e.BrowserDefaultFocusStyleProvider && Object.hasOwn(e, "BrowserDefaultFocusStyleProvider")),
12
12
  );
13
- const componentRegexes = componentNames.map((n) => new RegExp(`"data-encore-id":(?:[a-zA-Z_\$][\w\$]*\\.){2}${n}\\b`));
13
+ const componentRegexes = componentNames.map(
14
+ (n) => new RegExp(String.raw`"data-encore-id":(?:[a-zA-Z_$][\w$]*\.){2}${n}\b`),
15
+ );
14
16
  const componentPairs = [exportedFunctions.map((f) => [f, f]), exportedForwardRefs.map((f) => [(f as any).render, f])]
15
17
  .flat()
16
- .map(([s, f]) => [componentNames.find((n, i) => src(s).match(componentRegexes[i])), f]);
18
+ .map(([s, f]) => {
19
+ const code = src(s);
20
+ return [
21
+ code.includes('"data-encore-id"')
22
+ ? componentNames.find((_, i) => componentRegexes[i].test(code))
23
+ : undefined,
24
+ f,
25
+ ];
26
+ });
17
27
 
18
28
  export const UI: any = Object.fromEntries(componentPairs);
@@ -4,8 +4,10 @@
4
4
  */
5
5
 
6
6
  import { warn } from "../logger.ts";
7
+ import { sourceOf } from "../util.ts";
7
8
  import { postWebpackRequireHooks, WebpackModule, WebpackRequire, webpackRequire } from "../wpunpk.mix.ts";
8
9
  import { createCaptureReadiness } from "./capture-readiness.ts";
10
+ import { watchRegistryQuiet } from "./registry-quiet.ts";
9
11
 
10
12
  export let modules: Array<[PropertyKey, WebpackModule]>;
11
13
  export let exports: Array<Record<string, any>>;
@@ -18,19 +20,7 @@ export let exportedContexts: Array<React.Context<any>>;
18
20
  export let exportedForwardRefs: Array<any>;
19
21
  export let exportedMemos: React.NamedExoticComponent[];
20
22
 
21
- // Some client exports are functions whose own toString is not callable; they
22
- // can never match a needle, so they stringify to "".
23
- export const src = (f: unknown): string => {
24
- try {
25
- return String(f);
26
- } catch {
27
- try {
28
- return Function.prototype.toString.call(f);
29
- } catch {
30
- return "";
31
- }
32
- }
33
- };
23
+ export const src = sourceOf;
34
24
 
35
25
  export const analyzeWebpackRequire = (webpackRequire: WebpackRequire) => {
36
26
  const modules = Object.entries(webpackRequire.m) as Array<[keyof any, WebpackModule]>;
@@ -84,18 +74,31 @@ Object.assign(CHUNKS, {
84
74
  // executed by capture time. Lazy route chunks go through the runtime's own
85
75
  // script loader (wpr.l) from then on.
86
76
  postWebpackRequireHooks.push((wpr: any) => {
77
+ let loading = 0;
87
78
  if (typeof wpr?.l === "function") {
88
79
  const load = wpr.l.bind(wpr);
89
- wpr.l = (url: string, done: (event: unknown) => unknown, key?: string, chunkId?: unknown) =>
90
- load(
91
- url,
92
- (event: unknown) => {
93
- resolveChunk(new URL(url, location.href).pathname);
94
- return done(event);
95
- },
96
- key,
97
- chunkId,
98
- );
80
+ wpr.l = (url: string, done: (event: unknown) => unknown, key?: string, chunkId?: unknown) => {
81
+ loading++;
82
+ try {
83
+ return load(
84
+ url,
85
+ (event: unknown) => {
86
+ loading--;
87
+ try {
88
+ resolveChunk(new URL(url, location.href).pathname);
89
+ } catch (error) {
90
+ console.error(`[stdlib] could not resolve chunk ${url}:`, error);
91
+ }
92
+ return done(event);
93
+ },
94
+ key,
95
+ chunkId,
96
+ );
97
+ } catch (error) {
98
+ loading--;
99
+ throw error;
100
+ }
101
+ };
99
102
  }
100
103
  // Chunk scripts that finished loading before capture. The xpui entry
101
104
  // chunks are deliberately excluded: they are gated below on registry
@@ -108,23 +111,18 @@ postWebpackRequireHooks.push((wpr: any) => {
108
111
  }
109
112
  }
110
113
  // Capture can fire while boot is still registering modules (xpui-modules
111
- // and friends land after the runtime is up, and the analysis needles live
112
- // there). The analysis below snapshots wpr.m once, so hold the xpui
113
- // promises until the registry has been quiet for a few ticks — with a
114
- // hard cap so a pathological boot still resolves.
115
- let last = -1;
116
- let stable = 0;
117
- let ticks = 0;
118
- const settle = setInterval(() => {
119
- const count = Object.keys(wpr?.m ?? {}).length;
120
- stable = count === last ? stable + 1 : 0;
121
- last = count;
122
- if (stable >= 3 || ++ticks > 100) {
123
- clearInterval(settle);
114
+ // and the lazy chunks the client requests right after it land later, and
115
+ // analysis needles such as GenericModal live there). The analysis below
116
+ // snapshots wpr.m once, so hold the xpui promises until the registry has
117
+ // been quiet, with no chunk in flight, for 300ms.
118
+ watchRegistryQuiet(
119
+ () => Object.keys(wpr?.m ?? {}).length,
120
+ () => loading,
121
+ () => {
124
122
  resolveChunk("/vendor~xpui.js");
125
123
  resolveChunk("/xpui.js");
126
- }
127
- }, 100);
124
+ },
125
+ );
128
126
  });
129
127
 
130
128
  // Capture subscribers run synchronously right after the analysis lands, so
@@ -135,7 +133,7 @@ postWebpackRequireHooks.push((wpr: any) => {
135
133
  let captured = false;
136
134
  const captureSubscribers: Array<() => void> = [];
137
135
  const webpackCaptureReadiness = createCaptureReadiness({
138
- // Registry quiescence caps at about 10.1s. Leave room for its last tick,
136
+ // Registry quiescence caps at about 10s. Leave room for its last tick,
139
137
  // then release degraded so a changed runtime cannot deadlock all modules.
140
138
  timeoutMs: 12000,
141
139
  onTimeout: () => warn("[stdlib] capture health: webpack capture timed out; module surfaces will be degraded"),
@@ -0,0 +1,30 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ const QUIET_MS = 300;
7
+ const TICK_MS = 25;
8
+ const CAP_MS = 10_000;
9
+
10
+ // watchRegistryQuiet calls onQuiet once the module registry has not changed,
11
+ // and no chunk load has been in flight, for 300ms. A 10s cap bounds the wait
12
+ // so a chunk that never finishes, or a registry that never settles, still
13
+ // resolves.
14
+ export function watchRegistryQuiet(count: () => number, pending: () => number, onQuiet: () => void): void {
15
+ const started = Date.now();
16
+ let last = -1;
17
+ let quietSince = started;
18
+ const timer = setInterval(() => {
19
+ const current = count();
20
+ const at = Date.now();
21
+ if (current !== last || pending() > 0) {
22
+ last = current;
23
+ quietSince = at;
24
+ }
25
+ if (at - quietSince >= QUIET_MS || at - started >= CAP_MS) {
26
+ clearInterval(timer);
27
+ onQuiet();
28
+ }
29
+ }, TICK_MS);
30
+ }