@needle-tools/engine 5.1.0-alpha.8 → 5.1.0-beta

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 (122) 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-3LOy9tj8.js → needle-engine.bundle-BWkDSRyF.js} +6248 -6001
  7. package/dist/{needle-engine.bundle-BuI2pjv1.min.js → needle-engine.bundle-BvgS8xMP.min.js} +171 -171
  8. package/dist/{needle-engine.bundle-DQy9vQdJ.umd.cjs → needle-engine.bundle-CdVr_iIe.umd.cjs} +142 -142
  9. package/dist/needle-engine.d.ts +359 -46
  10. package/dist/needle-engine.js +618 -606
  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_license.d.ts +7 -7
  25. package/lib/engine/engine_license.js +72 -72
  26. package/lib/engine/engine_license.js.map +1 -1
  27. package/lib/engine/engine_math.d.ts +1 -1
  28. package/lib/engine/engine_math.js +10 -4
  29. package/lib/engine/engine_math.js.map +1 -1
  30. package/lib/engine/engine_networking_blob.js +3 -3
  31. package/lib/engine/engine_networking_blob.js.map +1 -1
  32. package/lib/engine/engine_scenedata.js +7 -0
  33. package/lib/engine/engine_scenedata.js.map +1 -1
  34. package/lib/engine/engine_serialization_core.js +7 -2
  35. package/lib/engine/engine_serialization_core.js.map +1 -1
  36. package/lib/engine/engine_time_utils.d.ts +10 -6
  37. package/lib/engine/engine_time_utils.js +10 -6
  38. package/lib/engine/engine_time_utils.js.map +1 -1
  39. package/lib/engine/engine_utils_qrcode.js +2 -2
  40. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  41. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  42. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  43. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  44. package/lib/engine/webcomponents/needle-engine.js +2 -2
  45. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  46. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  47. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  48. package/lib/engine/xr/TempXRContext.js +2 -2
  49. package/lib/engine/xr/TempXRContext.js.map +1 -1
  50. package/lib/engine-components/AudioSource.d.ts +46 -2
  51. package/lib/engine-components/AudioSource.js +268 -60
  52. package/lib/engine-components/AudioSource.js.map +1 -1
  53. package/lib/engine-components/Component.js +2 -1
  54. package/lib/engine-components/Component.js.map +1 -1
  55. package/lib/engine-components/DragControls.js +2 -2
  56. package/lib/engine-components/DragControls.js.map +1 -1
  57. package/lib/engine-components/DragControlsConstraints.d.ts +0 -15
  58. package/lib/engine-components/DragControlsConstraints.js +14 -8
  59. package/lib/engine-components/DragControlsConstraints.js.map +1 -1
  60. package/lib/engine-components/EventList.js +1 -1
  61. package/lib/engine-components/EventList.js.map +1 -1
  62. package/lib/engine-components/SceneSwitcher.d.ts +2 -0
  63. package/lib/engine-components/SceneSwitcher.js +43 -16
  64. package/lib/engine-components/SceneSwitcher.js.map +1 -1
  65. package/lib/engine-components/SeeThrough.d.ts +39 -0
  66. package/lib/engine-components/SeeThrough.js +69 -1
  67. package/lib/engine-components/SeeThrough.js.map +1 -1
  68. package/lib/engine-components/api.d.ts +1 -0
  69. package/lib/engine-components/api.js +1 -0
  70. package/lib/engine-components/api.js.map +1 -1
  71. package/lib/engine-components/codegen/components.d.ts +10 -0
  72. package/lib/engine-components/codegen/components.js +10 -0
  73. package/lib/engine-components/codegen/components.js.map +1 -1
  74. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  75. package/lib/engine-components/timeline/PlayableDirector.js +1 -1
  76. package/lib/engine-components/timeline/PlayableDirector.js.map +1 -1
  77. package/lib/engine-components/web/ScrollFollow.d.ts +33 -0
  78. package/lib/engine-components/web/ScrollFollow.js +164 -22
  79. package/lib/engine-components/web/ScrollFollow.js.map +1 -1
  80. package/lib/needle-app.d.ts +18 -0
  81. package/lib/needle-app.js +19 -0
  82. package/lib/needle-app.js.map +1 -0
  83. package/package.json +3 -2
  84. package/plugins/common/license.js +25 -4
  85. package/plugins/common/needle-engine.js +5 -2
  86. package/plugins/types/userconfig.d.ts +22 -0
  87. package/plugins/vite/dts-generator.d.ts +24 -0
  88. package/plugins/vite/dts-generator.js +40 -7
  89. package/plugins/vite/index.d.ts +2 -0
  90. package/plugins/vite/index.js +17 -0
  91. package/plugins/vite/license.js +4 -4
  92. package/plugins/vite/needle-app.js +78 -5
  93. package/plugins/vite/remote.d.ts +28 -0
  94. package/plugins/vite/remote.js +124 -0
  95. package/src/engine/api.ts +1 -1
  96. package/src/engine/engine_audio.ts +68 -15
  97. package/src/engine/engine_gameobject.ts +1 -1
  98. package/src/engine/engine_init.ts +2 -2
  99. package/src/engine/engine_license.ts +69 -69
  100. package/src/engine/engine_math.ts +11 -4
  101. package/src/engine/engine_networking_blob.ts +3 -3
  102. package/src/engine/engine_scenedata.ts +7 -2
  103. package/src/engine/engine_serialization_core.ts +7 -2
  104. package/src/engine/engine_time_utils.ts +10 -6
  105. package/src/engine/engine_utils_qrcode.ts +2 -2
  106. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  107. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  108. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  109. package/src/engine/webcomponents/needle-engine.ts +2 -2
  110. package/src/engine/xr/TempXRContext.ts +2 -2
  111. package/src/engine-components/AudioSource.ts +256 -55
  112. package/src/engine-components/Component.ts +2 -1
  113. package/src/engine-components/DragControls.ts +3 -2
  114. package/src/engine-components/DragControlsConstraints.ts +16 -8
  115. package/src/engine-components/EventList.ts +1 -1
  116. package/src/engine-components/SceneSwitcher.ts +37 -15
  117. package/src/engine-components/SeeThrough.ts +75 -1
  118. package/src/engine-components/api.ts +1 -0
  119. package/src/engine-components/codegen/components.ts +10 -0
  120. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  121. package/src/engine-components/timeline/PlayableDirector.ts +1 -1
  122. package/src/engine-components/web/ScrollFollow.ts +168 -22
@@ -111,7 +111,7 @@ async function requestNeedleCloud(path, options) {
111
111
  * @param {DefaultOptions & {accessToken?:string, team:string|undefined}} opts
112
112
  */
113
113
  export async function replaceLicense(code, opts) {
114
- const index = code?.indexOf("miOmwPp");
114
+ const index = code?.indexOf("$rJboqN");
115
115
  if (index >= 0) {
116
116
  const licenseResult = await resolveLicense(opts);
117
117
  if (!licenseResult) {
@@ -120,18 +120,18 @@ export async function replaceLicense(code, opts) {
120
120
  const end = code.indexOf(";", index);
121
121
  if (end >= 0) {
122
122
  const line = code.substring(index, end);
123
- const replaced = "miOmwPp = \"" + licenseResult.type + "\"";
123
+ const replaced = "$rJboqN = \"" + licenseResult.type + "\"";
124
124
  code = code.replace(line, replaced);
125
125
  }
126
126
 
127
127
  // Also inject JWT if available
128
128
  if (licenseResult.jwt) {
129
- const jwtIndex = code.indexOf("_$ALiqbpo");
129
+ const jwtIndex = code.indexOf("_$RRhFut");
130
130
  if (jwtIndex >= 0) {
131
131
  const jwtEnd = code.indexOf(";", jwtIndex);
132
132
  if (jwtEnd >= 0) {
133
133
  const jwtLine = code.substring(jwtIndex, jwtEnd);
134
- const jwtReplaced = "_$ALiqbpo = \"" + licenseResult.jwt + "\"";
134
+ const jwtReplaced = "_$RRhFut = \"" + licenseResult.jwt + "\"";
135
135
  code = code.replace(jwtLine, jwtReplaced);
136
136
  }
137
137
  }
@@ -318,11 +318,32 @@ function tryParseLicense(str, options = undefined) {
318
318
  }
319
319
 
320
320
 
321
+ /** @type {Map<string, Promise<string | null>>} */
322
+ const _publicIdentifierCache = new Map();
323
+
321
324
  /**
325
+ * Memoized public project identifier. Several plugins (defines, needle-app, next) request this per build,
326
+ * but it only needs to be resolved once - cache the promise by project_id to avoid redundant Needle Cloud
327
+ * requests. Keyed by project_id only; `opts` only controls logging and does not affect the result.
322
328
  * @param {string | undefined} project_id
323
329
  * @param {DefaultOptions | undefined} opts
330
+ * @returns {Promise<string | null>}
324
331
  */
325
332
  export async function getPublicIdentifier(project_id, opts = undefined) {
333
+ const key = project_id ?? "";
334
+ let cached = _publicIdentifierCache.get(key);
335
+ if (!cached) {
336
+ cached = getPublicIdentifierUncached(project_id, opts);
337
+ _publicIdentifierCache.set(key, cached);
338
+ }
339
+ return cached;
340
+ }
341
+
342
+ /**
343
+ * @param {string | undefined} project_id
344
+ * @param {DefaultOptions | undefined} opts
345
+ */
346
+ async function getPublicIdentifierUncached(project_id, opts = undefined) {
326
347
 
327
348
  let accessToken = /** @type {string | undefined} */ (undefined);
328
349
  if (!accessToken) {
@@ -1,8 +1,11 @@
1
1
 
2
2
 
3
- // https://regex101.com/r/SVhzzD/1
3
+ // Matches the `src` attribute of a <needle-engine> tag.
4
+ // The value capture must accept any real src: relative paths (with dots, slashes, spaces),
5
+ // full URLs, query strings, and the JSON-array form `["a.glb","b.glb"]`. It must also
6
+ // tolerate other attributes appearing before or after `src`.
4
7
  // @ts-ignore
5
- const needleEngineRegex = /<needle-engine.*?src=["'](?<src>[\w\d]+?)["']>/gm;
8
+ const needleEngineRegex = /<needle-engine\b[^>]*?\ssrc=(?<q>["'])(?<src>.*?)\k<q>/gms;
6
9
 
7
10
 
8
11
  /**
@@ -175,6 +175,28 @@ export type userSettings = {
175
175
  */
176
176
  openBrowser?: boolean;
177
177
 
178
+ /**
179
+ * Enable remote debugging: bind the dev server to your local network (`host`)
180
+ * and serve over HTTPS (via `@vitejs/plugin-basic-ssl`) so you can open the page
181
+ * on a phone, Quest or Apple Vision Pro on the same Wi‑Fi. The Needle logger
182
+ * already streams the device's console, errors and device info back over Vite's
183
+ * websocket (see `node_modules/.needle/logs/latest.client.needle.log`), so this
184
+ * makes "debug a scene on a real device from your laptop" work out of the box.
185
+ * HTTPS is required for WebXR / secure‑context features on the device; the
186
+ * self‑signed certificate shows a one‑time warning you accept on the device.
187
+ *
188
+ * Requires `@vitejs/plugin-basic-ssl` (for HTTPS) and, optionally,
189
+ * `qrcode-terminal` (to print a scannable QR code). Can also be toggled with the
190
+ * `NEEDLE_REMOTE` env var (`NEEDLE_REMOTE=1` / `=false`).
191
+ * - `true` — enable with a terminal QR code
192
+ * - `{ qr: false }` — enable but don't print the QR code
193
+ * @default false
194
+ */
195
+ remote?: boolean | {
196
+ /** Set to false to skip printing the scannable QR code. @default true */
197
+ qr?: boolean;
198
+ };
199
+
178
200
  disableLogging?: boolean;
179
201
 
180
202
  /** Set to true to disable the plugin that ensures VSCode workspace settings for custom-elements.json data */
@@ -1,3 +1,27 @@
1
+ /**
2
+ * Pure helper: compute the desired `html.customData` array for a project.
3
+ *
4
+ * Returns `null` when no change is required (the existing array already matches),
5
+ * otherwise the new array that should be written.
6
+ *
7
+ * The produced entry is a project-relative path with forward slashes. We use
8
+ * `path.relative` rather than a string replace because Vite normalises projectRoot
9
+ * to forward slashes while `htmlDataPath` carries the platform separator from
10
+ * `path.join` — a naive replace fails to match on Windows and leaves an absolute path.
11
+ * Any pre-existing entry pointing at a `needle-html-data.json` (e.g. an absolute path
12
+ * written by an older version of this plugin) is dropped so we don't accumulate
13
+ * duplicates or leave stale absolute paths behind.
14
+ *
15
+ * Exported for testing. The `relativeFn` seam lets tests reproduce platform-specific
16
+ * separator behaviour (`path.win32.relative` / `path.posix.relative`) deterministically.
17
+ *
18
+ * @param {unknown[]} existing Current html.customData entries
19
+ * @param {string} projectRoot
20
+ * @param {string} htmlDataPath Absolute path to the generated needle-html-data.json
21
+ * @param {(from: string, to: string) => string} [relativeFn]
22
+ * @returns {string[] | null}
23
+ */
24
+ export function computeHtmlCustomData(existing: unknown[], projectRoot: string, htmlDataPath: string, relativeFn?: (from: string, to: string) => string): string[] | null;
1
25
  /**
2
26
  * @param {"build" | "serve"} _command Vite command (unused — runs in both modes)
3
27
  * @param {import('../types/needleConfig').needleMeta | null | undefined} _config
@@ -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("miOmwPp");
52
+ const index = src.indexOf("$rJboqN");
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 = "miOmwPp = \"" + licenseResult.type + "\"";
57
+ const replaced = "$rJboqN = \"" + 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("_$ALiqbpo");
65
+ const jwtIndex = src.indexOf("_$RRhFut");
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 = "_$ALiqbpo = \"" + licenseResult.jwt + "\"";
70
+ const jwtReplaced = "_$RRhFut = \"" + 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 { bLW, __Carg, _$QyV } from "./engine_license.js";
233
+ export { $fwGZrGe, _$qWUhYZU, _XtEQ } from "./engine_license.js";
234
234
 
235
235
 
236
236
  // ============================================================================