@needle-tools/engine 5.1.0-alpha.7 → 5.1.0-alpha.9

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 (115) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/components.needle.json +1 -1
  3. package/dist/{materialx-CE2sUv2B.umd.cjs → materialx-B47Bz-xs.umd.cjs} +1 -1
  4. package/dist/{materialx-u1EqYrhu.min.js → materialx-B85WjP7E.min.js} +1 -1
  5. package/dist/{materialx-vyB2Zbt4.js → materialx-jkHmVPez.js} +1 -1
  6. package/dist/{needle-engine.bundle-BNj2FJS7.min.js → needle-engine.bundle-BotdnwVF.min.js} +173 -173
  7. package/dist/{needle-engine.bundle-tHGdwr9a.umd.cjs → needle-engine.bundle-DagaNrZK.umd.cjs} +147 -147
  8. package/dist/{needle-engine.bundle-BwS5IsLn.js → needle-engine.bundle-mtqHZ45d.js} +7832 -7624
  9. package/dist/needle-engine.d.ts +301 -23
  10. package/dist/needle-engine.js +575 -565
  11. package/dist/needle-engine.min.js +1 -1
  12. package/dist/needle-engine.umd.cjs +1 -1
  13. package/dist/{vendor-ButPLzor.umd.cjs → vendor-1UvpPPSB.umd.cjs} +2 -2
  14. package/dist/{vendor-DqZC4Is7.min.js → vendor-BQ2Vuntm.min.js} +4 -4
  15. package/dist/{vendor-DkWSNjMV.js → vendor-BslSKZPo.js} +113 -106
  16. package/lib/engine/api.d.ts +1 -1
  17. package/lib/engine/api.js +1 -1
  18. package/lib/engine/api.js.map +1 -1
  19. package/lib/engine/engine_audio.d.ts +30 -1
  20. package/lib/engine/engine_audio.js +57 -13
  21. package/lib/engine/engine_audio.js.map +1 -1
  22. package/lib/engine/engine_init.js +2 -2
  23. package/lib/engine/engine_init.js.map +1 -1
  24. package/lib/engine/engine_instantiate_resolve.js +40 -10
  25. package/lib/engine/engine_instantiate_resolve.js.map +1 -1
  26. package/lib/engine/engine_license.d.ts +7 -7
  27. package/lib/engine/engine_license.js +72 -72
  28. package/lib/engine/engine_license.js.map +1 -1
  29. package/lib/engine/engine_math.d.ts +1 -1
  30. package/lib/engine/engine_math.js +10 -4
  31. package/lib/engine/engine_math.js.map +1 -1
  32. package/lib/engine/engine_networking_blob.js +3 -3
  33. package/lib/engine/engine_networking_blob.js.map +1 -1
  34. package/lib/engine/engine_scenedata.js +7 -0
  35. package/lib/engine/engine_scenedata.js.map +1 -1
  36. package/lib/engine/engine_serialization_core.js +7 -2
  37. package/lib/engine/engine_serialization_core.js.map +1 -1
  38. package/lib/engine/engine_time_utils.d.ts +10 -6
  39. package/lib/engine/engine_time_utils.js +10 -6
  40. package/lib/engine/engine_time_utils.js.map +1 -1
  41. package/lib/engine/engine_utils_qrcode.js +2 -2
  42. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  43. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  44. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  45. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  46. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  47. package/lib/engine/webcomponents/needle-engine.js +2 -2
  48. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  49. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  50. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  51. package/lib/engine/xr/TempXRContext.js +2 -2
  52. package/lib/engine/xr/TempXRContext.js.map +1 -1
  53. package/lib/engine-components/AudioSource.d.ts +46 -2
  54. package/lib/engine-components/AudioSource.js +268 -60
  55. package/lib/engine-components/AudioSource.js.map +1 -1
  56. package/lib/engine-components/DragControls.js +2 -2
  57. package/lib/engine-components/DragControls.js.map +1 -1
  58. package/lib/engine-components/DragControlsConstraints.d.ts +0 -15
  59. package/lib/engine-components/DragControlsConstraints.js +14 -8
  60. package/lib/engine-components/DragControlsConstraints.js.map +1 -1
  61. package/lib/engine-components/EventList.js +1 -1
  62. package/lib/engine-components/EventList.js.map +1 -1
  63. package/lib/engine-components/SceneSwitcher.js +17 -2
  64. package/lib/engine-components/SceneSwitcher.js.map +1 -1
  65. package/lib/engine-components/codegen/components.d.ts +10 -0
  66. package/lib/engine-components/codegen/components.js +10 -0
  67. package/lib/engine-components/codegen/components.js.map +1 -1
  68. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  69. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  70. package/lib/engine-components/timeline/PlayableDirector.d.ts +6 -0
  71. package/lib/engine-components/timeline/PlayableDirector.js +25 -12
  72. package/lib/engine-components/timeline/PlayableDirector.js.map +1 -1
  73. package/lib/engine-components/web/ScrollFollow.d.ts +33 -0
  74. package/lib/engine-components/web/ScrollFollow.js +164 -22
  75. package/lib/engine-components/web/ScrollFollow.js.map +1 -1
  76. package/lib/needle-app.d.ts +18 -0
  77. package/lib/needle-app.js +19 -0
  78. package/lib/needle-app.js.map +1 -0
  79. package/package.json +3 -2
  80. package/plugins/common/license.js +25 -4
  81. package/plugins/common/needle-engine.js +5 -2
  82. package/plugins/types/userconfig.d.ts +22 -0
  83. package/plugins/vite/dts-generator.d.ts +24 -0
  84. package/plugins/vite/dts-generator.js +40 -7
  85. package/plugins/vite/index.d.ts +2 -0
  86. package/plugins/vite/index.js +17 -0
  87. package/plugins/vite/license.js +4 -4
  88. package/plugins/vite/needle-app.js +78 -5
  89. package/plugins/vite/remote.d.ts +28 -0
  90. package/plugins/vite/remote.js +124 -0
  91. package/src/engine/api.ts +1 -1
  92. package/src/engine/engine_audio.ts +68 -15
  93. package/src/engine/engine_init.ts +2 -2
  94. package/src/engine/engine_instantiate_resolve.ts +37 -10
  95. package/src/engine/engine_license.ts +69 -69
  96. package/src/engine/engine_math.ts +11 -4
  97. package/src/engine/engine_networking_blob.ts +3 -3
  98. package/src/engine/engine_scenedata.ts +7 -2
  99. package/src/engine/engine_serialization_core.ts +7 -2
  100. package/src/engine/engine_time_utils.ts +10 -6
  101. package/src/engine/engine_utils_qrcode.ts +2 -2
  102. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  103. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  104. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  105. package/src/engine/webcomponents/needle-engine.ts +2 -2
  106. package/src/engine/xr/TempXRContext.ts +2 -2
  107. package/src/engine-components/AudioSource.ts +256 -55
  108. package/src/engine-components/DragControls.ts +3 -2
  109. package/src/engine-components/DragControlsConstraints.ts +16 -8
  110. package/src/engine-components/EventList.ts +1 -1
  111. package/src/engine-components/SceneSwitcher.ts +14 -2
  112. package/src/engine-components/codegen/components.ts +10 -0
  113. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  114. package/src/engine-components/timeline/PlayableDirector.ts +23 -12
  115. package/src/engine-components/web/ScrollFollow.ts +168 -22
@@ -16,7 +16,7 @@
16
16
  * needleDtsGenerator(command, needleConfig, userSettings)
17
17
  */
18
18
 
19
- import { join, resolve, dirname } from 'path';
19
+ import { join, resolve, dirname, relative } from 'path';
20
20
  import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'fs';
21
21
  import { tryLoadProjectConfig } from './config.js';
22
22
  import { generateBindingsDts } from '../dts-generator/index.js';
@@ -35,6 +35,40 @@ try {
35
35
  // will silently skip generation rather than crashing the dev server.
36
36
  }
37
37
 
38
+ /**
39
+ * Pure helper: compute the desired `html.customData` array for a project.
40
+ *
41
+ * Returns `null` when no change is required (the existing array already matches),
42
+ * otherwise the new array that should be written.
43
+ *
44
+ * The produced entry is a project-relative path with forward slashes. We use
45
+ * `path.relative` rather than a string replace because Vite normalises projectRoot
46
+ * to forward slashes while `htmlDataPath` carries the platform separator from
47
+ * `path.join` — a naive replace fails to match on Windows and leaves an absolute path.
48
+ * Any pre-existing entry pointing at a `needle-html-data.json` (e.g. an absolute path
49
+ * written by an older version of this plugin) is dropped so we don't accumulate
50
+ * duplicates or leave stale absolute paths behind.
51
+ *
52
+ * Exported for testing. The `relativeFn` seam lets tests reproduce platform-specific
53
+ * separator behaviour (`path.win32.relative` / `path.posix.relative`) deterministically.
54
+ *
55
+ * @param {unknown[]} existing Current html.customData entries
56
+ * @param {string} projectRoot
57
+ * @param {string} htmlDataPath Absolute path to the generated needle-html-data.json
58
+ * @param {(from: string, to: string) => string} [relativeFn]
59
+ * @returns {string[] | null}
60
+ */
61
+ export function computeHtmlCustomData(existing, projectRoot, htmlDataPath, relativeFn = relative) {
62
+ const relPath = relativeFn(projectRoot, htmlDataPath).replace(/\\/g, "/");
63
+
64
+ const cleaned = /** @type {string[]} */ (existing.filter(entry =>
65
+ typeof entry !== "string" || !entry.replace(/\\/g, "/").endsWith("needle-html-data.json")));
66
+ const next = [...cleaned, relPath];
67
+
68
+ if (existing.length === next.length && existing.every((v, i) => v === next[i])) return null;
69
+ return next;
70
+ }
71
+
38
72
  /**
39
73
  * Ensure `.vscode/settings.json` references the generated `needle-html-data.json`
40
74
  * so VS Code provides `data-bind-needle` completions in HTML files automatically.
@@ -48,9 +82,6 @@ function ensureVscodeHtmlCustomData(projectRoot, htmlDataPath) {
48
82
  const vscodeDir = join(projectRoot, ".vscode");
49
83
  const settingsPath = join(vscodeDir, "settings.json");
50
84
 
51
- // Relative path from project root for portability
52
- const relPath = htmlDataPath.replace(projectRoot + "/", "").replace(projectRoot + "\\", "");
53
-
54
85
  /** @type {Record<string, unknown>} */
55
86
  let settings = {};
56
87
  if (existsSync(settingsPath)) {
@@ -60,10 +91,12 @@ function ensureVscodeHtmlCustomData(projectRoot, htmlDataPath) {
60
91
  }
61
92
 
62
93
  const key = "html.customData";
63
- const existing = Array.isArray(settings[key]) ? /** @type {string[]} */ (settings[key]) : [];
64
- if (existing.includes(relPath)) return; // already registered
94
+ const existing = Array.isArray(settings[key]) ? /** @type {unknown[]} */ (settings[key]) : [];
95
+
96
+ const next = computeHtmlCustomData(existing, projectRoot, htmlDataPath);
97
+ if (next === null) return; // already up-to-date
65
98
 
66
- settings[key] = [...existing, relPath];
99
+ settings[key] = next;
67
100
 
68
101
  mkdirSync(vscodeDir, { recursive: true });
69
102
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
@@ -56,7 +56,9 @@ export { needleFacebookInstantGames } from "./facebook-instant-games.js";
56
56
  export { needleImportsLogger } from "./imports-logger.js";
57
57
  export { needleBuildInfo } from "./buildinfo.js";
58
58
  export { needleApp } from "./needle-app.js";
59
+ export { needleLogger } from "./logger.js";
59
60
  export { needleServer } from "./server.js";
60
61
  export * from "./gzip.js";
61
62
  export * from "./config.js";
62
63
  export { needleMakeFilesLocal, needleLocalFilesSceneAnalysis } from "./local-files.js";
64
+ export { needleRemote, isRemoteEnabled } from "./remote.js";
@@ -82,6 +82,10 @@ import { needleServer } from "./server.js";
82
82
  import { needleNPM } from "./npm.js";
83
83
  import { needleTransformCode } from "./transform.js";
84
84
  import { needleLogger } from "./logger.js";
85
+ export { needleLogger } from "./logger.js";
86
+ import { needleRemote, isRemoteEnabled } from "./remote.js";
87
+ export { needleRemote, isRemoteEnabled } from "./remote.js";
88
+ import { needleLog } from "./logging.js";
85
89
  import { viteFixWorkerImport } from "../common/worker.js";
86
90
 
87
91
  export { needleServer } from "./server.js";
@@ -163,11 +167,24 @@ export async function needlePlugins(command = undefined, config = undefined, use
163
167
  needleServer(command, config, userSettings),
164
168
  needleNPM(command, config, userSettings),
165
169
  needleApp(command, config, userSettings),
170
+ needleRemote(command, config, userSettings),
166
171
  viteFixWorkerImport(),
167
172
  // IMPORTANT: needleBuildInfo should be the last plugin, since it waits for the build pipeline to finish and then collects information about the build output directory
168
173
  needleBuildInfo(command, config, userSettings),
169
174
  ];
170
175
 
176
+ // Remote debugging serves over HTTPS so WebXR / secure‑context features work on
177
+ // the device. HTTPS is provided by @vitejs/plugin-basic-ssl (a peer the user
178
+ // installs when they opt in); load it lazily so it's never required otherwise.
179
+ if (command !== "build" && isRemoteEnabled(userSettings)) {
180
+ try {
181
+ const { default: basicSsl } = await import("@vitejs/plugin-basic-ssl");
182
+ array.push(basicSsl());
183
+ } catch {
184
+ needleLog("needle-remote", "Remote HTTPS needs `@vitejs/plugin-basic-ssl` — install it: npm i -D @vitejs/plugin-basic-ssl", "warn");
185
+ }
186
+ }
187
+
171
188
  const asap = await needleAsap(command, config, userSettings);
172
189
  if(asap) array.push(asap);
173
190
 
@@ -49,12 +49,12 @@ export function needleLicense(command, config, userSettings) {
49
49
  let modified = false;
50
50
 
51
51
  // Replace license type
52
- const index = src.indexOf("__nNJ");
52
+ const index = src.indexOf("DUZpOVcM");
53
53
  if (index >= 0) {
54
54
  const end = src.indexOf(";", index);
55
55
  if (end >= 0) {
56
56
  const line = src.substring(index, end);
57
- const replaced = "__nNJ = \"" + licenseResult.type + "\"";
57
+ const replaced = "DUZpOVcM = \"" + licenseResult.type + "\"";
58
58
  src = src.replace(line, replaced);
59
59
  modified = true;
60
60
  }
@@ -62,12 +62,12 @@ export function needleLicense(command, config, userSettings) {
62
62
 
63
63
  // Replace license JWT (same pattern)
64
64
  if (licenseResult.jwt) {
65
- const jwtIndex = src.indexOf("_ldXlow");
65
+ const jwtIndex = src.indexOf("__Bjqq");
66
66
  if (jwtIndex >= 0) {
67
67
  const jwtEnd = src.indexOf(";", jwtIndex);
68
68
  if (jwtEnd >= 0) {
69
69
  const jwtLine = src.substring(jwtIndex, jwtEnd);
70
- const jwtReplaced = "_ldXlow = \"" + licenseResult.jwt + "\"";
70
+ const jwtReplaced = "__Bjqq = \"" + licenseResult.jwt + "\"";
71
71
  src = src.replace(jwtLine, jwtReplaced);
72
72
  modified = true;
73
73
  }
@@ -1,6 +1,7 @@
1
1
  import { writeFile } from 'fs';
2
2
  import { tryParseNeedleEngineSrcAttributeFromHtml } from '../common/needle-engine.js';
3
3
  import { needleLog } from './logging.js';
4
+ import { getPublicIdentifier } from '../common/license.js';
4
5
 
5
6
 
6
7
 
@@ -21,6 +22,22 @@ export function needleApp(command, config, userSettings) {
21
22
 
22
23
  let outputDir = "dist";
23
24
 
25
+ // The engine API is emitted as a dedicated, deterministically-named facade chunk so that the
26
+ // generated needle-app.js can re-export it - giving users a single import path (the same URL they
27
+ // load needle-app.js from also serves the engine API). See buildStart below.
28
+ // Emitted INTO assets/ (alongside the app's other chunks) on purpose: the app entry lives in assets/
29
+ // and then references the facade with a same-directory specifier ("./needle-app.engine.js"). A root-level
30
+ // facade would be referenced as "../needle-app.engine.js", and Vite computes preload-dependency hints with
31
+ // Node's path.relative, which returns backslashes on Windows for cross-directory paths ("..\\needle-app.engine.js")
32
+ // - a broken modulepreload URL. Keeping it in assets/ avoids the "../" entirely.
33
+ const apiFacadeFileName = "assets/needle-app.engine.js";
34
+ let apiFacadeEmitted = false;
35
+
36
+ // Public project identifier, baked as a comment into the generated needle-app.js for traceability.
37
+ // This is the Needle Cloud `public_key` (the same value injected into client code as NEEDLE_PUBLIC_KEY) -
38
+ // it is PUBLIC and safe to embed. It is NOT the secret NEEDLE_CLOUD_TOKEN. Null when no token is configured.
39
+ let publicKey = null;
40
+
24
41
  /**
25
42
  * @type {import('vite').Plugin}
26
43
  */
@@ -28,6 +45,35 @@ export function needleApp(command, config, userSettings) {
28
45
  {
29
46
  name: 'needle:app',
30
47
  enforce: "post",
48
+ async buildStart() {
49
+ // Emit @needle-tools/engine/needle-app (a thin re-export of the public API) as its own
50
+ // entry chunk with a fixed filename. Built in the same pass as the app, it deduplicates
51
+ // into the same engine chunks - so importing hooks from it drives the SAME running scene.
52
+ try {
53
+ const resolved = await this.resolve('@needle-tools/engine/needle-app');
54
+ if (resolved?.id) {
55
+ this.emitFile({
56
+ type: 'chunk',
57
+ id: resolved.id,
58
+ fileName: apiFacadeFileName,
59
+ // Preserve the full re-exported API surface. Without this, the build tree-shakes the
60
+ // facade down to only the single export the app internally consumes, so a page-side
61
+ // `import { onStart } from ".../needle-app.js"` fails with "does not provide an export".
62
+ preserveSignature: 'allow-extension',
63
+ });
64
+ apiFacadeEmitted = true;
65
+ }
66
+ else {
67
+ needleLog("needle-app", "Could not resolve @needle-tools/engine/needle-app - engine hooks (onStart etc.) will not be importable from needle-app.js", "warn", { dimBody: false });
68
+ }
69
+ }
70
+ catch (e) {
71
+ needleLog("needle-app", "Failed to emit " + apiFacadeFileName + " facade: " + e.message, "warn", { dimBody: false });
72
+ }
73
+
74
+ // Fetch the public project identifier (best-effort; null without a NEEDLE_CLOUD_TOKEN).
75
+ publicKey = await getPublicIdentifier(undefined).catch(() => null);
76
+ },
31
77
  configResolved(config) {
32
78
  outputDir = config.build.outDir || "dist";
33
79
  },
@@ -52,7 +98,7 @@ export function needleApp(command, config, userSettings) {
52
98
 
53
99
  const main_asset = attribute_src?.[0] || imported_glbs?.[0];
54
100
 
55
- const webComponent = generateNeedleEmbedWebComponent(path, main_asset);
101
+ const webComponent = generateNeedleEmbedWebComponent(path, main_asset, apiFacadeEmitted ? apiFacadeFileName : null, publicKey);
56
102
  await writeFile(`${outputDir}/needle-app.js`, webComponent, (err) => {
57
103
  if (err) {
58
104
  needleLog("needle-app", "Could not create needle-app.js: " + err.message, "error", { dimBody: false });
@@ -78,9 +124,11 @@ export function needleApp(command, config, userSettings) {
78
124
  /**
79
125
  * @param {string} filepath
80
126
  * @param {string | null} src
127
+ * @param {string | null | undefined} apiFacadeFile Filename of the emitted engine-API facade chunk (e.g. "needle-app.engine.js"). When set, its API is re-exported so engine hooks can be imported from needle-app.js.
128
+ * @param {string | null | undefined} publicKey Public Needle Cloud project identifier, baked into the file header as a comment for traceability.
81
129
  * @returns {string}
82
130
  */
83
- function generateNeedleEmbedWebComponent(filepath, src) {
131
+ function generateNeedleEmbedWebComponent(filepath, src, apiFacadeFile, publicKey) {
84
132
 
85
133
 
86
134
  // filepath is e.g. `assets/index-XXXXXXXX.js`
@@ -89,8 +137,27 @@ function generateNeedleEmbedWebComponent(filepath, src) {
89
137
 
90
138
  const componentDefaultName = 'needle-app';
91
139
 
92
- return `
140
+ // Bake the asset path in as a safely-escaped string literal: paths can contain quotes/spaces/backticks,
141
+ // and JSON string literals are valid JS string literals. No asset → serializes to the literal `null`,
142
+ // matching the previous default. Actual origin resolution happens at runtime via `new URL(...)` below.
143
+ const defaultSrc = JSON.stringify(src ?? null);
144
+
145
+ // Re-export the engine API facade so users have a SINGLE import path: the same URL they load
146
+ // needle-app.js from also serves the engine hooks (`import { onStart } from "<deploy>/needle-app.js"`).
147
+ // The facade is a separate, deterministically-named chunk that shares the app's engine instance,
148
+ // so the re-export resolves to the live scene. Specifier is relative to this file's own URL.
149
+ const apiReexport = apiFacadeFile
150
+ ? `export * from ${JSON.stringify("./" + apiFacadeFile)};\n`
151
+ : ``;
93
152
 
153
+ // File header for traceability: which build produced this file, and (if available) the public project id.
154
+ // Values are sanitized to a single line so they cannot break out of the `//` comment.
155
+ const oneLine = (/** @type {string} */ v) => String(v).replace(/[\r\n]+/g, " ").trim();
156
+ let header = `// needle-app.js - generated by the @needle-tools/engine needle-app plugin\n`;
157
+ header += `// build time: ${oneLine(new Date().toISOString())}\n`;
158
+ if (publicKey) header += `// public key: ${oneLine(publicKey)}\n`;
159
+
160
+ return `${header}${apiReexport}
94
161
  // Needle Engine attributes we want to allow to be overriden
95
162
  const knownAttributes = [
96
163
  "src",
@@ -170,8 +237,14 @@ if (!customElements.get(componentName)) {
170
237
  updateAttributes() {
171
238
  console.debug("NeedleApp updating attributes");
172
239
 
173
- const src = this.getAttribute('src') || ${src?.length ? `\`\${this.basePath}/${src}\`` : null};
174
- if(src) this.needleEngine.setAttribute("src", src);
240
+ let src = this.getAttribute('src') || ${defaultSrc};
241
+ if(src) {
242
+ // Resolve the asset URL against this script's own origin (import.meta.url) so relative
243
+ // paths keep resolving to the deployed app even when <needle-app> is embedded on a
244
+ // different domain (e.g. Webflow). Absolute URLs pass through unchanged; spaces get encoded.
245
+ src = new URL(src, this.basePath + '/').href;
246
+ this.needleEngine.setAttribute("src", src);
247
+ }
175
248
  else this.needleEngine.removeAttribute("src");
176
249
 
177
250
  for(const attr of knownAttributes) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Whether remote debugging is enabled. Toggled by the `remote` user setting or the
3
+ * `NEEDLE_REMOTE` env var (the env var wins so you can flip it without editing the
4
+ * config: `NEEDLE_REMOTE=1 npm run dev`, or `NEEDLE_REMOTE=false` to force off).
5
+ * @param {import('../types/userconfig.js').userSettings} [userSettings]
6
+ * @returns {boolean}
7
+ */
8
+ export function isRemoteEnabled(userSettings?: import("../types/userconfig.js").userSettings): boolean;
9
+ /**
10
+ * Remote debugging plugin.
11
+ *
12
+ * Binds the Vite dev server to the LAN (`server.host = true`) so other devices can
13
+ * reach it, and prints the network URL + a scannable QR code once the server is
14
+ * listening. HTTPS itself is provided by `@vitejs/plugin-basic-ssl`, which
15
+ * {@link needlePlugins} adds to the plugin array when remote is enabled (WebXR and
16
+ * other secure‑context features on the device require https).
17
+ *
18
+ * The page's console / errors / device info already stream back to the dev server
19
+ * via the Needle logger (`needleLogger` + `logger.client.js`) over Vite's
20
+ * websocket, landing in `node_modules/.needle/logs/latest.client.needle.log`. This
21
+ * plugin is read‑only: it does not (yet) inject commands back into the page.
22
+ *
23
+ * @param {"build" | "serve" | undefined} command
24
+ * @param {unknown} _config
25
+ * @param {import('../types/userconfig.js').userSettings} [userSettings]
26
+ * @returns {import('vite').Plugin | null}
27
+ */
28
+ export function needleRemote(command: "build" | "serve" | undefined, _config: unknown, userSettings?: import("../types/userconfig.js").userSettings): import("vite").Plugin | null;
@@ -0,0 +1,124 @@
1
+ // @ts-check
2
+ import os from "os";
3
+ import { needleLog } from "./logging.js";
4
+
5
+ /**
6
+ * Whether remote debugging is enabled. Toggled by the `remote` user setting or the
7
+ * `NEEDLE_REMOTE` env var (the env var wins so you can flip it without editing the
8
+ * config: `NEEDLE_REMOTE=1 npm run dev`, or `NEEDLE_REMOTE=false` to force off).
9
+ * @param {import('../types/userconfig.js').userSettings} [userSettings]
10
+ * @returns {boolean}
11
+ */
12
+ export function isRemoteEnabled(userSettings) {
13
+ const env = process.env.NEEDLE_REMOTE;
14
+ if (env !== undefined && env !== "") {
15
+ const v = env.toLowerCase();
16
+ return !(v === "false" || v === "0" || v === "off" || v === "no");
17
+ }
18
+ const r = userSettings?.remote;
19
+ return r === true || (typeof r === "object" && r !== null);
20
+ }
21
+
22
+ /**
23
+ * Pick the first non-internal IPv4 LAN address — the one a phone / headset on the
24
+ * same Wi‑Fi can reach. Prefers the common private ranges and otherwise falls back
25
+ * to the first external IPv4 found.
26
+ * @returns {string | null}
27
+ */
28
+ function lanAddress() {
29
+ const ifaces = os.networkInterfaces();
30
+ /** @type {string | null} */
31
+ let fallback = null;
32
+ for (const name of Object.keys(ifaces)) {
33
+ for (const net of ifaces[name] || []) {
34
+ // Node reports family as the string "IPv4" (older versions: the number 4)
35
+ if (net.family !== "IPv4" && /** @type {unknown} */ (net.family) !== 4) continue;
36
+ if (net.internal) continue;
37
+ if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(net.address)) return net.address;
38
+ if (!fallback) fallback = net.address;
39
+ }
40
+ }
41
+ return fallback;
42
+ }
43
+
44
+ /**
45
+ * Remote debugging plugin.
46
+ *
47
+ * Binds the Vite dev server to the LAN (`server.host = true`) so other devices can
48
+ * reach it, and prints the network URL + a scannable QR code once the server is
49
+ * listening. HTTPS itself is provided by `@vitejs/plugin-basic-ssl`, which
50
+ * {@link needlePlugins} adds to the plugin array when remote is enabled (WebXR and
51
+ * other secure‑context features on the device require https).
52
+ *
53
+ * The page's console / errors / device info already stream back to the dev server
54
+ * via the Needle logger (`needleLogger` + `logger.client.js`) over Vite's
55
+ * websocket, landing in `node_modules/.needle/logs/latest.client.needle.log`. This
56
+ * plugin is read‑only: it does not (yet) inject commands back into the page.
57
+ *
58
+ * @param {"build" | "serve" | undefined} command
59
+ * @param {unknown} _config
60
+ * @param {import('../types/userconfig.js').userSettings} [userSettings]
61
+ * @returns {import('vite').Plugin | null}
62
+ */
63
+ export function needleRemote(command, _config, userSettings) {
64
+ if (command === "build") return null;
65
+ if (!isRemoteEnabled(userSettings)) return null;
66
+
67
+ const r = userSettings?.remote;
68
+ const showQr = !(typeof r === "object" && r !== null && r.qr === false);
69
+ let announced = false;
70
+
71
+ return {
72
+ name: "needle:remote",
73
+ enforce: "pre",
74
+ config(config) {
75
+ if (!config.server) config.server = {};
76
+ // Bind 0.0.0.0 so phones / headsets on the same network can connect.
77
+ // Respect an explicit host the user already set.
78
+ if (config.server.host === undefined) config.server.host = true;
79
+ },
80
+ configureServer(server) {
81
+ const announce = async () => {
82
+ if (announced) return;
83
+ const addr = server.httpServer?.address?.();
84
+ const port = (addr && typeof addr === "object" && addr.port) || server.config.server.port;
85
+ const ip = lanAddress();
86
+ if (!port || !ip) {
87
+ needleLog("needle-remote", "Remote debugging is ON but no LAN address was found — is this machine on a network?", "warn");
88
+ return;
89
+ }
90
+ announced = true;
91
+ const protocol = server.config.server.https ? "https" : "http";
92
+ const url = `${protocol}://${ip}:${port}/`;
93
+ const note = protocol === "https"
94
+ ? "\n (accept the one‑time self‑signed certificate warning on the device)"
95
+ : "\n (no HTTPS — install @vitejs/plugin-basic-ssl for WebXR / secure‑context features)";
96
+ needleLog("needle-remote", `Remote debugging is ON — open on a device on the same Wi‑Fi:\n ${url}${note}`);
97
+ // The device's console / errors stream back over Vite's HMR websocket
98
+ // (via the Needle logger). With HMR disabled there's no socket, so the
99
+ // logs would silently never arrive — warn rather than leave you guessing.
100
+ if (server.config.server.hmr === false) {
101
+ needleLog("needle-remote", "HMR is disabled, so device console logs won't stream back (they ride the HMR websocket). Enable HMR to capture remote logs.", "warn");
102
+ }
103
+ if (showQr) await printQr(url);
104
+ };
105
+ // `listening` fires once the port is bound; resolve the real port from it.
106
+ server.httpServer?.once("listening", announce);
107
+ },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Print a scannable QR code of the URL to the terminal (best effort — needs the
113
+ * optional `qrcode-terminal` package; if it's missing we just say how to get it).
114
+ * @param {string} url
115
+ */
116
+ async function printQr(url) {
117
+ try {
118
+ const mod = await import("qrcode-terminal");
119
+ const qrcode = /** @type {{ generate: (text: string, opts?: object) => void }} */ (mod.default || mod);
120
+ qrcode.generate(url, { small: true });
121
+ } catch {
122
+ needleLog("needle-remote", "Install `qrcode-terminal` to show a scannable QR code here: npm i -D qrcode-terminal", "log");
123
+ }
124
+ }
package/src/engine/api.ts CHANGED
@@ -230,7 +230,7 @@ export * from "./engine_input.js";
230
230
  export { InstancingUtil } from "./engine_instancing.js";
231
231
 
232
232
  /** License checking utilities */
233
- export { _IrDl, _LMtzpq, $HDGDr } from "./engine_license.js";
233
+ export { _imGyfK, _QchMjAD, rkHTARX } from "./engine_license.js";
234
234
 
235
235
 
236
236
  // ============================================================================
@@ -1,25 +1,78 @@
1
1
  import { AudioContext } from "three";
2
2
 
3
3
  import { Application } from "./engine_application.js";
4
+ import { getParam } from "./engine_utils.js";
4
5
 
5
- /**
6
- * @internal
7
- * Ensure the audio context is resumed if it gets suspended or interrupted */
6
+ /** Verbose audio logging, opt-in via `?debugaudio` (same flag AudioSource uses). */
7
+ const debug = getParam("debugaudio");
8
+
9
+ /**
10
+ * iOS Safari exposes a non-standard `"interrupted"` state in addition to the
11
+ * spec'd `AudioContextState`s when the audio session is taken away (e.g. the
12
+ * device is locked or another app starts playing audio).
13
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state#resuming_interrupted_play_states_in_ios_safari
14
+ */
15
+ export type InterruptibleAudioContextState = AudioContextState | "interrupted";
16
+
17
+ /**
18
+ * @internal
19
+ * Pure policy deciding whether `AudioContext.resume()` should be attempted right now.
20
+ *
21
+ * The context needs resuming when it is `"suspended"` or (on iOS) `"interrupted"`.
22
+ * But resuming is only allowed while the document is **visible**: on iOS Safari,
23
+ * calling `resume()` while the page is backgrounded or the device is locked fails
24
+ * with `InvalidStateError: Failed to start the audio device`, because the audio
25
+ * hardware cannot be started in the background. In that case the resume must be
26
+ * deferred until the page becomes visible again.
27
+ *
28
+ * @param state The current (possibly iOS-interrupted) audio context state.
29
+ * @param visibility The current `document.visibilityState`.
30
+ * @returns `true` if `resume()` should be called now, `false` if it should be deferred.
31
+ */
32
+ export function shouldResumeAudioContext(
33
+ state: InterruptibleAudioContextState,
34
+ visibility: DocumentVisibilityState,
35
+ ): boolean {
36
+ // Resuming while hidden throws InvalidStateError on iOS — defer until visible.
37
+ if (visibility !== "visible") return false;
38
+ return state === "suspended" || state === "interrupted";
39
+ }
40
+
41
+ /** Resume the shared AudioContext if it is suspended/interrupted and the page is visible. */
42
+ function recoverAudioContext() {
43
+ const ctx = AudioContext.getContext();
44
+ const state = ctx.state as InterruptibleAudioContextState;
45
+ const visibility = typeof document !== "undefined" ? document.visibilityState : "visible";
46
+ if (!shouldResumeAudioContext(state, visibility)) return;
47
+ ctx.resume()
48
+ .then(() => { if (debug) console.log("AudioContext resumed successfully"); })
49
+ .catch(e => { if (debug) console.warn("Failed to resume AudioContext: " + e); });
50
+ }
51
+
52
+ /**
53
+ * @internal
54
+ * Keep the shared AudioContext alive across suspends/interruptions (tab switch, device lock, audio
55
+ * session interruption). On returning to the foreground we simply resume() the EXISTING context —
56
+ * the same approach three.js uses, which recovers cleanly because AudioSource plays clips through a
57
+ * MediaElementAudioSourceNode (an <audio> element), whose media session iOS keeps alive across a
58
+ * lock. We deliberately do NOT close/recreate the context: on-device testing showed that makes iOS
59
+ * worse (a recreated context's output stays dead) and it is unnecessary.
60
+ */
8
61
  export function ensureAudioContextIsResumed() {
9
62
  Application.registerWaitForInteraction(() => {
10
- // this is a fix for https://github.com/mrdoob/three.js/issues/27779 & https://linear.app/needle/issue/NE-4257
63
+ // https://github.com/mrdoob/three.js/issues/27779 & https://linear.app/needle/issue/NE-4257
11
64
  const ctx = AudioContext.getContext();
12
- ctx.addEventListener("statechange", () => {
13
- setTimeout(() => {
14
- // on iOS the audiocontext can be interrupted: https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state#resuming_interrupted_play_states_in_ios_safari
15
- const state = ctx.state as AudioContextState | "interrupted";
16
- if (state === "suspended" || state === "interrupted") {
17
- ctx.resume()
18
- .then(() => { console.log("AudioContext resumed successfully"); })
19
- .catch((e) => { console.log("Failed to resume AudioContext: " + e); });
20
- }
21
- }, 500);
22
- });
65
+ ctx.addEventListener("statechange", () => setTimeout(recoverAudioContext, 300));
66
+
67
+ // iOS frequently rejects the first resume() right after foregrounding with InvalidStateError,
68
+ // then accepts one a few hundred ms later with no follow-up statechange — so retry with a
69
+ // short backoff. Each attempt is a no-op once running.
70
+ if (typeof document !== "undefined") {
71
+ document.addEventListener("visibilitychange", () => {
72
+ if (document.visibilityState !== "visible") return;
73
+ for (const delay of [0, 250, 600, 1200, 2000]) setTimeout(recoverAudioContext, delay);
74
+ });
75
+ }
23
76
  });
24
77
  }
25
78
 
@@ -8,7 +8,7 @@ import { initBuiltinTypes } from "./codegen/register_types.js";
8
8
  import { initSpatialConsole } from "./debug/debug_spatial_console.js";
9
9
  import { initAddressableSerializers } from "./engine_addressables.js";
10
10
  import { ensureAudioContextIsResumed } from "./engine_audio.js";
11
- import { _$zdhiyteo } from "./engine_license.js";
11
+ import { _NHJZozV } from "./engine_license.js";
12
12
  import { initNeedleLoader } from "./engine_loaders.js";
13
13
  import { initPhysics } from "./engine_physics_rapier.js";
14
14
  import { initBuiltinSerializers } from "./engine_serialization_builtin_serializer.js";
@@ -59,7 +59,7 @@ export function initEngine() {
59
59
  initPhysics();
60
60
  initXR();
61
61
  initSpatialConsole();
62
- _$zdhiyteo();
62
+ _NHJZozV();
63
63
 
64
64
  // Must be last. customElements.define() upgrades existing <needle-engine>
65
65
  // elements synchronously, which can trigger context.create() and dispatch
@@ -137,19 +137,39 @@ export function resolveInstanceReferences(objectMap: InstantiateReferenceMap): v
137
137
  }
138
138
  }
139
139
 
140
+ /**
141
+ * Per-call traversal counters. Only populated when `?debuginstantiate` is set; null otherwise so
142
+ * the hot path stays allocation-free. Safe as module state because resolution runs synchronously
143
+ * and these calls never nest.
144
+ */
145
+ type ResolveStats = { objects: number; components: number; props: number; remapped: number };
146
+ let _resolveStats: ResolveStats | null = null;
147
+
140
148
  /**
141
149
  * Resolves string-based guid references in all components of a hierarchy using a GuidsMap.
142
150
  * Used by the glTF loading path where objects get new guids assigned and string references
143
151
  * (e.g. PlayableDirector track.outputs) need to be updated.
144
152
  */
145
153
  export function resolveStringGuidsInHierarchy(root: Object3D, guidsMap: GuidsMap): void {
154
+ if (!debug) {
155
+ resolveStringGuidsRecursive(root, guidsMap);
156
+ return;
157
+ }
158
+ _resolveStats = { objects: 0, components: 0, props: 0, remapped: 0 };
159
+ const start = (typeof performance !== "undefined" ? performance.now() : Date.now());
146
160
  resolveStringGuidsRecursive(root, guidsMap);
161
+ const ms = (typeof performance !== "undefined" ? performance.now() : Date.now()) - start;
162
+ const s = _resolveStats;
163
+ _resolveStats = null;
164
+ console.log(`[resolveStringGuids] root="${root.name}" objects=${s.objects} components=${s.components} propsVisited=${s.props} remapped=${s.remapped} mapSize=${Object.keys(guidsMap).length} in ${ms.toFixed(2)}ms`);
147
165
  }
148
166
 
149
167
  function resolveStringGuidsRecursive(obj: Object3D, guidsMap: GuidsMap): void {
168
+ if (_resolveStats) _resolveStats.objects++;
150
169
  if (obj.userData?.components) {
151
170
  for (const component of obj.userData.components) {
152
171
  if (component === null) continue;
172
+ if (_resolveStats) _resolveStats.components++;
153
173
  resolveStringGuidsInObject(component, guidsMap);
154
174
  }
155
175
  }
@@ -166,26 +186,33 @@ function resolveStringGuidsInObject(obj: any, guidsMap: GuidsMap, visited?: Weak
166
186
  visited.add(obj);
167
187
 
168
188
  for (const key of Object.keys(obj)) {
189
+ if (_resolveStats) _resolveStats.props++;
169
190
  const value = obj[key];
170
191
  if (value === null || value === undefined) continue;
171
192
  if (typeof value === "string") {
172
- if (guidsMap[value]) {
173
- obj[key] = guidsMap[value];
174
- }
193
+ const mapped = guidsMap[value];
194
+ if (mapped) { obj[key] = mapped; if (_resolveStats) _resolveStats.remapped++; }
175
195
  }
176
196
  else if (Array.isArray(value)) {
177
197
  for (let i = 0; i < value.length; i++) {
178
- if (typeof value[i] === "string" && guidsMap[value[i]]) {
179
- value[i] = guidsMap[value[i]];
198
+ const entry = value[i];
199
+ if (typeof entry === "string") {
200
+ const mapped = guidsMap[entry];
201
+ if (mapped) { value[i] = mapped; if (_resolveStats) _resolveStats.remapped++; }
180
202
  }
181
- else if (typeof value[i] === "object" && value[i] !== null) {
182
- resolveStringGuidsInObject(value[i], guidsMap, visited);
203
+ // Only descend into plain data (arrays and plain objects). See isPlainObject note below.
204
+ else if (Array.isArray(entry) || isPlainObject(entry)) {
205
+ resolveStringGuidsInObject(entry, guidsMap, visited);
183
206
  }
184
207
  }
185
208
  }
186
- else if (typeof value === "object") {
187
- // Skip known non-data objects
188
- if (value.isObject3D || value.isComponent) continue;
209
+ // Only descend into plain objects. String guids only ever live in serialized plain-object
210
+ // data (e.g. PlayableDirector track.outputs). We must NOT traverse class instances —
211
+ // Object3D, Component, three.js value/render types (Material, Texture, BufferGeometry) or
212
+ // TypedArrays. Descending into the scene graph or geometry buffers (e.g. Object.keys on a
213
+ // multi-million-element Float32Array) makes hierarchy guid resolution scale with mesh data
214
+ // and dominates load time.
215
+ else if (isPlainObject(value)) {
189
216
  resolveStringGuidsInObject(value, guidsMap, visited);
190
217
  }
191
218
  }