Needle Engine

Changes between version 3.32.22-alpha and 3.32.23-alpha
Files changed (12) hide show
  1. plugins/vite/index.js +30 -3
  2. src/engine/engine_addressables.ts +72 -3
  3. src/engine/engine_context.ts +0 -2
  4. src/engine/engine_gameobject.ts +2 -2
  5. src/engine/engine_input.ts +73 -17
  6. plugins/types/index.d.ts +2 -1
  7. src/engine-components/ui/PointerEvents.ts +4 -4
  8. src/engine-components/ui/SpatialHtml.ts +15 -2
  9. plugins/types/userconfig.d.ts +5 -0
  10. src/engine-components/VideoPlayer.ts +1 -1
  11. plugins/vite/pwa.js +418 -0
  12. plugins/types/webmanifest.d.ts +26 -0
plugins/vite/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { needleDefines } from "./defines.js";
2
2
  export { needleDefines } from "./defines.js";
3
3
 
4
+ import { needleBuildPipeline } from "./build-pipeline.js";
5
+ export { needleBuildPipeline } from "./build-pipeline.js";
6
+
4
7
  import { needleBuild } from "./build.js";
5
8
  export { needleBuild } from "./build.js";
6
9
 
@@ -10,6 +13,9 @@
10
13
  import { needlePoster } from "./poster.js"
11
14
  export { needlePoster } from "./poster.js"
12
15
 
16
+ import { needlePWA } from "./pwa.js";
17
+ export { needlePWA } from "./pwa.js";
18
+
13
19
  import { needleReload } from "./reload.js"
14
20
  export { needleReload } from "./reload.js"
15
21
 
@@ -43,7 +49,6 @@
43
49
  import { vite_4_4_hack } from "./vite-4.4-hack.js";
44
50
  import { needleImportsLogger } from "./imports-logger.js";
45
51
  import { needleBuildInfo } from "./buildinfo.js";
46
- import { needleBuildPipeline } from "./build-pipeline.js";
47
52
 
48
53
 
49
54
  export * from "./gzip.js";
@@ -54,12 +59,33 @@
54
59
  allowHotReload: true,
55
60
  }
56
61
 
57
- /**
62
+ /** # Needle Engine plugins for Vite
63
+ * Plugins include hot reload support, meta tags, defines, build pipeline, PWA, and more.
64
+ * ## Using PWA
65
+ * How to add PWA support to your vite project:
66
+ * 1) Install the [vite pwa plugin](https://vite-pwa-org.netlify.app/): `npm install vite-plugin-pwa --save-dev`
67
+ * 2) Add the following to your vite.config.js:
68
+ * You first pass the PWAOptions to the needlePlugins function, then you pass the same PWAOptions to the VitePWA plugin.
69
+ * You *can* use also add a `.webmanifest` file to your web project and edit the [PWA manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) settings there.
70
+ * ```js
71
+ * export default defineConfig(async ({ command }) => {
72
+ // \@type(import("vite-plugin-pwa").VitePWAOptions) (remove the backslash)
73
+ const PWAOptions = {};
74
+ ...
75
+ return {
76
+ plugins: [
77
+ ...
78
+ needlePlugins(command, needleConfig, { pwaOptions: PWAOptions }),
79
+ VitePWA(PWAOptions),
80
+ ],
81
+ }
82
+ * ```
83
+ * @param {string} command
58
84
  * @param {import('../types').userSettings} userSettings
59
85
  */
60
86
  export const needlePlugins = async (command, config, userSettings) => {
61
87
 
62
- if(!config) config = {}
88
+ if (!config) config = {}
63
89
 
64
90
  // ensure we have user settings initialized with defaults
65
91
  userSettings = { ...defaultUserSettings, ...userSettings }
@@ -81,6 +107,7 @@
81
107
  needleFacebookInstantGames(command, config, userSettings),
82
108
  needleImportsLogger(command, config, userSettings),
83
109
  needleBuildPipeline(command, config, userSettings),
110
+ needlePWA(command, config, userSettings),
84
111
  ];
85
112
  array.push(await editorConnection(command, config, userSettings, array));
86
113
  return array;
src/engine/engine_addressables.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { getLoader } from "./engine_gltf.js";
6
6
  import { processNewScripts } from "./engine_mainloop_utils.js";
7
7
  import { registerPrefabProvider, syncInstantiate } from "./engine_networking_instantiate.js";
8
- import { assign,SerializationContext, TypeSerializer } from "./engine_serialization_core.js";
8
+ import { assign, SerializationContext, TypeSerializer } from "./engine_serialization_core.js";
9
9
  import { Context } from "./engine_setup.js";
10
10
  import type { IComponent, IGameObject, SourceIdentifier } from "./engine_types.js";
11
11
  import { download } from "./engine_web_api.js";
@@ -413,9 +413,15 @@
413
413
 
414
414
 
415
415
 
416
-
417
416
  const failedTexturePromise = Promise.resolve(null);
418
417
 
418
+ /** Use this if a file is a external image URL
419
+ * @example
420
+ * ```ts
421
+ * @serializable(ImageReference)
422
+ * myImage?:ImageReference;
423
+ * ```
424
+ */
419
425
  export class ImageReference {
420
426
 
421
427
  private static imageReferences = new Map<string, ImageReference>();
@@ -507,4 +513,67 @@
507
513
  return undefined;
508
514
  }
509
515
  }
510
- new ImageReferenceSerializer();
516
+ new ImageReferenceSerializer();
517
+
518
+
519
+
520
+ /** Use this if a file is a external file URL. The file can be any arbitrary binary data like a videofile or a text asset
521
+ */
522
+ export class FileReference {
523
+
524
+ private static cache = new Map<string, FileReference>();
525
+
526
+ static getOrCreate(url: string) {
527
+ let ref = FileReference.cache.get(url);
528
+ if (!ref) {
529
+ ref = new FileReference(url);
530
+ FileReference.cache.set(url, ref);
531
+ }
532
+ return ref;
533
+ }
534
+
535
+ /** Load the file binary data
536
+ * @returns a promise that resolves to the binary data of the file. Make sure to await this request or use `.then(res => {...})` to get the result.
537
+ */
538
+ async loadRaw(): Promise<Blob> {
539
+ if (!this.res) this.res = fetch(this.url);
540
+ return this.res.then(res => res.blob());
541
+ }
542
+
543
+ /** Load the file as text (if the referenced file is a text file like a .txt or .json file)
544
+ * @returns a promise that resolves to the text data of the file. Make sure to await this request or use `.then(res => {...})` to get the result. If the format is json you can use `JSON.parse(result)` to convert it to a json object
545
+ */
546
+ async loadText(): Promise<string> {
547
+ if (!this.res) this.res = fetch(this.url);
548
+ return this.res.then(res => res.text());
549
+ }
550
+
551
+ /** The resolved url to the file */
552
+ readonly url: string;
553
+
554
+ private res?: Promise<Response>;
555
+
556
+ constructor(url: string) {
557
+ this.url = url;
558
+ }
559
+ }
560
+
561
+
562
+ export class FileReferenceSerializer extends TypeSerializer {
563
+ constructor() {
564
+ super([FileReference]);
565
+ }
566
+
567
+ onSerialize(_data: string, _context: SerializationContext) {
568
+ return null;
569
+ }
570
+
571
+ onDeserialize(data: string, _context: SerializationContext) {
572
+ if (typeof data === "string") {
573
+ const url = resolveUrl(_context.gltfId, data)
574
+ return FileReference.getOrCreate(url);
575
+ }
576
+ return undefined;
577
+ }
578
+ }
579
+ new FileReferenceSerializer();
src/engine/engine_context.ts CHANGED
@@ -431,8 +431,6 @@
431
431
  this.renderer.shadowMap.type = PCFSoftShadowMap;
432
432
  this.renderer.setSize(this.domWidth, this.domHeight);
433
433
  this.renderer.outputColorSpace = SRGBColorSpace;
434
- // https://github.com/mrdoob/three.js/pull/25556
435
- this.renderer.useLegacyLights = false;
436
434
 
437
435
  this.input.bindEvents();
438
436
  }
src/engine/engine_gameobject.ts CHANGED
@@ -198,8 +198,8 @@
198
198
  destroyed_objects.push(obj);
199
199
 
200
200
  // first disable and call onDestroy on components
201
- const components = obj.userData.components;
202
- if (components) {
201
+ const components = obj.userData?.components;
202
+ if (components != null && Array.isArray(components)) {
203
203
  let lastLength = components.length;
204
204
  for (let i = 0; i < components.length; i++) {
205
205
  const comp: Component = components[i];
src/engine/engine_input.ts CHANGED
@@ -69,6 +69,7 @@
69
69
  /** the browser event that triggered this event (if any) */
70
70
  readonly source: Event | null;
71
71
 
72
+ /** Is the pointer event created via a touch on screen or a spatial device like a XR controller or hand tracking? */
72
73
  readonly mode: XRTargetRayMode;
73
74
  /** A ray in worldspace for the event.
74
75
  * If the ray is undefined you can also use `space.worldForward` and `space.worldPosition` */
@@ -83,12 +84,20 @@
83
84
  /** true if this event is a double click */
84
85
  isDoubleClick: boolean = false;
85
86
 
87
+ /** @returns `true` if the event is marked to be used (when `use()` has been called). Default: `false` */
88
+ get used() { return this._used; }
89
+ private _used: boolean = false;
90
+ /** Call to mark an event to be used */
91
+ use() {
92
+ this._used = true;
93
+ }
86
94
 
87
95
  /** Unique identifier for this input: a combination of the deviceIndex + button to uniquely identify the exact input (e.g. LeftController:Button0 = 0, RightController:Button1 = 101) */
88
96
  override get pointerId(): number { return this._pointerid; }
89
97
  private readonly _pointerid;
90
98
 
91
99
  // this is set via the init arguments (we override it here for intellisense to show the string options)
100
+ /** What type of input created this event: touch, mouse, xr controller, xr hand tracking... */
92
101
  override get pointerType(): PointerTypeNames { return this._pointerType; }
93
102
  private readonly _pointerType: PointerTypeNames;
94
103
 
@@ -162,37 +171,84 @@
162
171
  declare type KeyboardEventListener = (evt: NEKeyboardEvent) => void;
163
172
  declare type InputEventListener = PointerEventListener | KeyboardEventListener;
164
173
 
174
+ export enum InputEventQueue {
175
+ Early = -100,
176
+ Default = 0,
177
+ Late = 100,
178
+ }
179
+
165
180
  export class Input implements IInput {
166
181
 
167
- private readonly _eventListeners: { [key: string]: InputEventListener[] } = {};
182
+ /** This is a list of event listeners per event type (e.g. pointerdown, pointerup, keydown...). Each entry contains a priority and list of listeners.
183
+ * That way users can control if they want to receive events before or after other listeners (e.g subscribe to pointer events before the EventSystem receives them) - this allows certain listeners to be always invoked first (or last) and stop propagation
184
+ * Listeners per event are sorted
185
+ */
186
+ private readonly _eventListeners: { [key: string]: Array<{ priority: number, listeners: InputEventListener[] }> } = {};
168
187
 
169
- addEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener): void {
188
+ /** Adds an event listener for the specified event type. The callback will be called when the event is triggered.
189
+ * @param type The event type to listen for
190
+ * @param callback The callback to call when the event is triggered
191
+ * @param queue The queue to add the listener to. Listeners in the same queue are called in the order they were added. Default is 0.
192
+ */
193
+ addEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener, queue: InputEventQueue = 0): void {
170
194
  if (!this._eventListeners[type]) this._eventListeners[type] = [];
171
- this._eventListeners[type].push(callback);
195
+ const listeners = this._eventListeners[type];
196
+ const queueListeners = listeners.find(l => l.priority === queue);
197
+ if (!queueListeners) {
198
+ listeners.push({ priority: queue, listeners: [callback] });
199
+ // ensure we sort the listeners by priority
200
+ listeners.sort((a, b) => a.priority - b.priority);
201
+ } else {
202
+ queueListeners.listeners.push(callback);
203
+ }
172
204
  }
173
- removeEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener): void {
205
+ /** Removes the event listener from the specified event type. If no queue is specified the listener will be removed from all queues.
206
+ * @param type The event type to remove the listener from
207
+ * @param callback The callback to remove
208
+ * @param queue The queue to remove the listener from. If no queue is specified the listener will be removed from all queues
209
+ */
210
+ removeEventListener(type: InputEvents | InputEventNames, callback: PointerEventListener, queue?: InputEventQueue): void {
174
211
  if (!this._eventListeners[type]) return;
175
- const index = this._eventListeners[type].indexOf(callback);
176
- if (index >= 0) this._eventListeners[type].splice(index, 1);
212
+ const listeners = this._eventListeners[type];
213
+ // if a specific queue is requested the callback should only be removed from that queue
214
+ if (queue !== undefined) {
215
+ const queueListeners = listeners.find(l => l.priority === queue);
216
+ if (!queueListeners) return;
217
+ const index = queueListeners.listeners.indexOf(callback);
218
+ if (index >= 0) queueListeners.listeners.splice(index, 1);
219
+ }
220
+ // if no queue is requested the callback will be removed from all queues
221
+ else {
222
+ for (const l of listeners) {
223
+ const index = l.listeners.indexOf(callback);
224
+ if (index >= 0) l.listeners.splice(index, 1);
225
+ }
226
+ }
177
227
  }
178
228
  private dispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) {
229
+ let propagationStopped = false;
179
230
  if (evt instanceof NEKeyboardEvent) {
180
231
  const listeners = this._eventListeners[evt.type];
181
232
  if (listeners) {
182
- for (const l of listeners) {
183
- (l as KeyboardEventListener)(evt);
233
+ for (const queue of listeners) {
234
+ for (const l of queue.listeners)
235
+ (l as KeyboardEventListener)(evt);
184
236
  }
185
237
  }
186
238
  }
187
239
  else if (evt instanceof NEPointerEvent) {
188
240
  const listeners = this._eventListeners[evt.type];
189
241
  if (listeners) {
190
- for (const l of listeners) {
191
- if (evt.immediatePropagationStopped) {
192
- if (debug) console.log("immediatePropagationStopped", evt.type);
193
- break;
242
+ for (const queue of listeners) {
243
+ if (propagationStopped) break;
244
+ for (const l of queue.listeners) {
245
+ if (evt.immediatePropagationStopped) {
246
+ propagationStopped = true;
247
+ if (debug) console.log("immediatePropagationStopped", evt.type);
248
+ break;
249
+ }
250
+ (l as PointerEventListener)(evt);
194
251
  }
195
- (l as PointerEventListener)(evt);
196
252
  }
197
253
  }
198
254
  }
@@ -917,14 +973,14 @@
917
973
 
918
974
  private isMouseEventEmmitedFromTouch(evt: NEPointerEvent) {
919
975
  if (evt.pointerType === PointerType.Mouse) {
920
- const lastPointer = this._pointerUpTimestamp.map((v, i) => ({ timestamp: v, index: i})).sort((a, b) => a.timestamp - b.timestamp).at(-1);
921
- if(lastPointer && this._pointerTypes[lastPointer.index] === PointerType.Touch) {
976
+ const lastPointer = this._pointerUpTimestamp.map((v, i) => ({ timestamp: v, index: i })).sort((a, b) => a.timestamp - b.timestamp).at(-1);
977
+ if (lastPointer && this._pointerTypes[lastPointer.index] === PointerType.Touch) {
922
978
  if (lastPointer.timestamp > 0 && evt.source?.timeStamp !== undefined) {
923
979
  const diff = (evt.source.timeStamp - lastPointer.timestamp);
924
980
  if (diff < 320 && diff >= 0) {
925
981
  // we received an UP event for a touch, ignore this DOWN event
926
982
  if (debug) console.log("Ignoring mouse.down for touch.up");
927
-
983
+
928
984
  return true;
929
985
  }
930
986
  }
@@ -947,7 +1003,7 @@
947
1003
 
948
1004
  private updatePointerPosition(evt: NEPointerEvent) {
949
1005
  const index = evt.pointerId;
950
-
1006
+
951
1007
  while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2());
952
1008
  while (index >= this._pointerPositionsLastFrame.length) this._pointerPositionsLastFrame.push(new Vector2());
953
1009
  while (index >= this._pointerPositionsDelta.length) this._pointerPositionsDelta.push(new Vector2());
plugins/types/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './userconfig';
2
- export * from "./needleConfig";
2
+ export * from "./needleConfig";
3
+ export * from "./webmanifest";
src/engine-components/ui/PointerEvents.ts CHANGED
@@ -35,16 +35,16 @@
35
35
  readonly buttonName: MouseButtonName | GamepadButtonName | undefined;
36
36
  get pressure(): number { return this.event.pressure; }
37
37
 
38
- private _used: boolean = false;
39
- /** true when `use()` has been called */
38
+ /** @returns `true` when `use()` has been called. Default: false */
40
39
  get used(): boolean {
41
40
  return this._used;
42
41
  }
43
-
42
+ private _used: boolean = false;
44
43
  /** mark this event to be used */
45
44
  use() {
46
45
  if (this._used) return;
47
46
  this._used = true;
47
+ this.event.use();
48
48
  if (this.pointerId !== undefined)
49
49
  this.input.setPointerUsed(this.pointerId);
50
50
  }
@@ -179,7 +179,7 @@
179
179
  const res = GameObject.foreachComponent(obj, comp => {
180
180
  // ignore disabled components
181
181
  if (!comp.enabled) return undefined;
182
-
182
+
183
183
  const handler = comp as IPointerEventHandler;
184
184
  // if a specific event is passed in, we only check for that event
185
185
  if (event) {
src/engine-components/ui/SpatialHtml.ts CHANGED
@@ -2,15 +2,25 @@
2
2
  import { HTMLMesh } from 'three/examples/jsm/interactive/HTMLMesh.js';
3
3
  import { InteractiveGroup } from 'three/examples/jsm/interactive/InteractiveGroup.js';
4
4
 
5
+ import { serializable } from '../../engine/engine_serialization.js';
5
6
  import { getWorldEuler, getWorldRotation, setWorldRotationXYZ } from '../../engine/engine_three_utils.js';
6
7
  import { Behaviour } from '../Component.js';
7
8
 
8
9
  export class SpatialHtml extends Behaviour {
9
10
 
11
+ @serializable()
10
12
  id: string | null = null;
13
+ @serializable()
11
14
  keepAspect: boolean = false;
12
15
 
13
- start() {
16
+ private _object: InteractiveGroup | null = null;
17
+
18
+ onEnable() {
19
+ if (this._object) {
20
+ this.gameObject.add(this._object);
21
+ return;
22
+ }
23
+
14
24
  if (!this.id || !this.context.mainCamera) return;
15
25
  const div = document.getElementById(this.id);
16
26
  if (!div) {
@@ -27,7 +37,6 @@
27
37
  group.add(mesh);
28
38
  mesh.visible = false;
29
39
 
30
- console.log(mesh);
31
40
  const mat = mesh.material as THREE.MeshBasicMaterial;
32
41
  mat.transparent = true;
33
42
 
@@ -61,4 +70,8 @@
61
70
  mesh.scale.multiply(factor);
62
71
  }, 1);
63
72
  }
73
+
74
+ onDisable(): void {
75
+ this._object?.removeFromParent();
76
+ }
64
77
  }
plugins/types/userconfig.d.ts CHANGED
@@ -43,6 +43,11 @@
43
43
  */
44
44
  posterGenerationMode?: "default" | "once";
45
45
 
46
+ /** When set to true the needle pwa plugin will not run */
47
+ noPWA?: boolean;
48
+ /** Pass in the VitePWA options */
49
+ pwaOptions?: {};
50
+
46
51
  /** used by nextjs config to forward the webpack module */
47
52
  modules: needleModules
48
53
  }
src/engine-components/VideoPlayer.ts CHANGED
@@ -144,7 +144,7 @@
144
144
  }
145
145
 
146
146
  get videoElement() {
147
- if (this._videoElement) if (!this.create(false)) return null;
147
+ if (!this._videoElement) if (!this.create(false)) return null;
148
148
  return this._videoElement!;
149
149
  }
150
150
 
plugins/vite/pwa.js ADDED
@@ -0,0 +1,418 @@
1
+
2
+ import { resolve, join, isAbsolute } from 'path'
3
+ import { readdirSync, existsSync, readFileSync, copyFileSync, writeFileSync, rmSync, mkdirSync } from 'fs';
4
+ import { builtAssetsDirectory, getOutputDirectory, tryLoadProjectConfig } from './config.js';
5
+
6
+
7
+
8
+ /** Process the PWA manifest
9
+ * @param {import('../types').userSettings} userSettings
10
+ * @returns {import('vite').Plugin}
11
+ */
12
+ export const needlePWA = (command, config, userSettings) => {
13
+ if (command !== "build") return;
14
+
15
+ if (config?.noPWA === true || userSettings?.noPWA === true) {
16
+ return;
17
+ }
18
+
19
+ const pwaOptions = userSettings.pwaOptions;
20
+
21
+ const currentDir = process.cwd();
22
+ const manifests = readdirSync(currentDir).filter(f => f.endsWith(".webmanifest")) || [];
23
+ if (manifests.length <= 0 && !pwaOptions) {
24
+ log("No webmanifests found in web project directory or in pwa options that are passed to the needle plugin (via `needlePlugin(command, needleConfig, { pwaOptions: PWAOptions })`)");
25
+ return;
26
+ }
27
+
28
+ /** The context contains files that are generated by the plugin and should be deleted after
29
+ * @type {import('../types').NeedlePWAProcessContext} */
30
+ const context = { generatedFiles: [] }
31
+
32
+ if (manifests.length <= 0) {
33
+ // write the manifes to disc temporarely
34
+ log("No webmanifests found in web project directory. Generating temporary webmanifest...");
35
+ const manifestName = "needle.generated.webmanifest";
36
+ const tempManifestPath = currentDir + "/" + manifestName;
37
+ const content = JSON.stringify(pwaOptions.manifest || {});
38
+ writeFileSync(tempManifestPath, content, 'utf8');
39
+ context.generatedFiles.push(tempManifestPath);
40
+ manifests.push(manifestName);
41
+ }
42
+
43
+ if (!pwaOptions) {
44
+ log("WARN: No PWA options found in user settings. Please pass in the pwaOptions to the needlePlugin like so: \"needlePlugins(command, needleConfig, { pwaOptions: PWAOptions })\" and pass the same options to vitePWA plugin like so \"VitePWA(PWAOptions)\"");
45
+ return cleanup(context);
46
+ }
47
+ else {
48
+ if (!pwaOptions.registerType) {
49
+ log("Set PWA registerType to autoUpdate. This will automatically update the service worker when the content changes. If you want to manually update the service worker set the registerType to manual. See https://vite-pwa-org.netlify.app/guide/#configuring-vite-plugin-pwa");
50
+ pwaOptions.registerType = "autoUpdate";
51
+ }
52
+ if (!pwaOptions.outDir) {
53
+ const outDir = getOutputDirectory();
54
+ log("Set PWA outDir to " + outDir);
55
+ pwaOptions.outDir = outDir;
56
+ }
57
+ }
58
+
59
+ // backup the original manifests
60
+ /** @type {Array<{path:string, copypath:string}>} */
61
+ const originalManifestContents = manifests.map(filename => {
62
+ const path = currentDir + "/" + filename;
63
+ const backupDir = currentDir + "/node_modules/.needle";
64
+ if (!existsSync(backupDir)) {
65
+ mkdirSync(backupDir, { recursive: true });
66
+ }
67
+ const copyPath = backupDir + "/" + filename + ".original";
68
+ log("Backup original webmanifest", path, "to", copyPath);
69
+ copyFileSync(path, copyPath);
70
+ return { path, copyPath };
71
+ });
72
+
73
+
74
+ // modify the manifests so they're loaded via VitePlugin
75
+ for (const manifest of manifests) {
76
+ try {
77
+ processPWA(manifest, context).catch(e => log("Error post processing PWA", manifest, e));
78
+ log("Assign PWA manifest to VitePWA plugin: \"" + manifest + "\"");
79
+ pwaOptions.manifest = {
80
+ start_url: "./index.html",
81
+ ...JSON.parse(readFileSync(manifest, 'utf8')),
82
+ ...pwaOptions.manifest
83
+ };
84
+ // we only want to process the first manifest
85
+ break;
86
+ }
87
+ catch (e) {
88
+ log("Error post processing PWA", manifest, e);
89
+ restoreOriginalManifests(originalManifestContents);
90
+ break;
91
+ }
92
+ }
93
+
94
+ /** @type {Array<string>} */
95
+ const postBuildMessages = [];
96
+
97
+ return {
98
+ name: 'needle-pwa',
99
+ apply: 'build',
100
+ enforce: "post",
101
+ config(config) {
102
+ try {
103
+ checkIfVitePWAPluginIsPresent(config);
104
+
105
+ // check if the index header contains the webmanifest ALSO
106
+ // if yes then we want to log a waring at the end
107
+ const indexPath = currentDir + "/index.html";
108
+ if (existsSync(indexPath)) {
109
+ const indexContent = readFileSync(indexPath, 'utf8');
110
+ if (indexContent.includes(".webmanifest")) {
111
+ throw new Error("ERR: [needle-pwa] index.html contains a reference to a webmanifest. This is not supported in PWA mode. Please remove the reference from the index.html");
112
+ }
113
+ }
114
+ }
115
+ catch (err) {
116
+ cleanup(context);
117
+ throw err;
118
+ }
119
+ finally {
120
+ restoreOriginalManifests(originalManifestContents);
121
+ }
122
+ },
123
+ transformIndexHtml: {
124
+ enforce: 'pre',
125
+ transform(html, _ctx) {
126
+ // see https://vite-pwa-org.netlify.app/guide/auto-update.html
127
+ // post transform so we want to linebreak after the vite logs
128
+ console.log("\n");
129
+ const scriptContent = `// Injected by needle-pwa
130
+ import { registerSW } from 'virtual:pwa-register'
131
+ registerSW({ immediate: true })`;
132
+ log("Inject PWA script into index.html");
133
+ return {
134
+ html,
135
+ tags: [
136
+ {
137
+ tag: 'script',
138
+ children: scriptContent,
139
+ attrs: { type: "module" },
140
+ injectTo: 'head',
141
+ },
142
+ ]
143
+ }
144
+ }
145
+ },
146
+ closeBundle() {
147
+ // copy the icons to the output directory
148
+ const outputDir = getOutputDirectory();
149
+ const webmanifestPath = outputDir + "/" + readdirSync(outputDir).find(f => f.endsWith(".webmanifest"));
150
+ if (webmanifestPath) {
151
+ try {
152
+ const manifest = JSON.parse(readFileSync(webmanifestPath, 'utf8'));
153
+ copyIcons(manifest, outputDir);
154
+ }
155
+ catch (e) {
156
+ log("Error post processing PWA", webmanifestPath, e);
157
+ }
158
+ }
159
+
160
+ cleanup(context);
161
+
162
+ for (const msg of postBuildMessages) {
163
+ console.log(msg);
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ /** Checks if the vite-plugin-pwa is present in the vite config
170
+ * @param {import('vite').UserConfig} config
171
+ */
172
+ function checkIfVitePWAPluginIsPresent(config) {
173
+ const plugins = config.plugins || [];
174
+ let foundVitePWAPlugin = false;
175
+ for (const plugin of plugins) {
176
+ if (isVitePWAPlugin(plugin)) {
177
+ foundVitePWAPlugin = true;
178
+ return;
179
+ }
180
+ }
181
+ function isVitePWAPlugin(p) {
182
+ if (Array.isArray(p)) {
183
+ return p.some(isVitePWAPlugin);
184
+ }
185
+ return p.name === "vite-plugin-pwa";
186
+ }
187
+ if (!foundVitePWAPlugin) {
188
+ // const { default: VitePWA } = await import('vite-plugin-pwa');
189
+ // log("Add vite-plugin-pwa to the vite config");
190
+ // config.plugins.push(VitePWA());
191
+ throw new Error("ERR: [needle-pwa] vite-plugin-pwa is not present in the vite config. Please install vite-plugin-pwa and add it to the vite config like so: \"VitePWA(PWAOptions)\". \n\nIf you don't intent to build a PWA then don't pass the PWA options to the needlePlugin or set `noPWA: true` in the user settings (third argument).");
192
+ }
193
+ }
194
+
195
+ function cleanup(context) {
196
+ for (const file of context.generatedFiles) {
197
+ log("Cleanup generated file", file);
198
+ rmSync(file);
199
+ }
200
+ context.generatedFiles.length = 0;
201
+ }
202
+
203
+ /**
204
+ * @param {Array<{path:string, copypath:string}>} manifests
205
+ */
206
+ function restoreOriginalManifests(manifests) {
207
+ // restore the manifest
208
+ for (const manifest of manifests) {
209
+ try {
210
+ log("Restore original webmanifest", manifest.path);
211
+ copyFileSync(manifest.copyPath, manifest.path);
212
+ rmSync(manifest.copyPath);
213
+ }
214
+ catch (e) {
215
+ log("Error restoring original webmanifest", manifest, e);
216
+ }
217
+ }
218
+ manifests.length = 0;
219
+ }
220
+
221
+
222
+ function log(...args) {
223
+ console.log("[needle-pwa]", ...args);
224
+ }
225
+ function delay(ms) {
226
+ return new Promise(resolve => setTimeout(resolve, ms));
227
+ }
228
+
229
+ /**
230
+ * @param {string} webmanifestPath
231
+ * @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
232
+ * @param {import("../types/index.d.ts").NeedlePWAOptions?} options
233
+ */
234
+ async function processPWA(webmanifestPath, context, options, iteration = 0) {
235
+ const outDir = getOutputDirectory();
236
+ if (!existsSync(webmanifestPath)) {
237
+ if (iteration > 2)
238
+ return log("No webmanifest found at", webmanifestPath);
239
+ // try a few times...
240
+ await delay(100);
241
+ return processPWA(webmanifestPath, context, options, iteration + 2);
242
+ }
243
+
244
+ /** @type {import("../types/webmanifest.d.ts").Webmanifest}} */
245
+ const manifest = JSON.parse(readFileSync(webmanifestPath, 'utf8'));
246
+ let modifiedManifest = false;
247
+
248
+ if (processIcons(manifest, outDir, context) === true) modifiedManifest = true;
249
+ if (processWorkboxConfig(manifest) === true) modifiedManifest = true;
250
+
251
+ const packageJsonPath = process.cwd() + "/package.json";
252
+ const packageJson = existsSync(packageJsonPath) ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : {};
253
+
254
+ if (manifest.id === undefined) {
255
+ let name = packageJson.name || manifest.short_name || manifest.name;
256
+ if (name) {
257
+ manifest.id = "com." + name.toLowerCase().replace(/[^a-z0-9\/\-]/g, '').replace("\/", ".");
258
+ modifiedManifest = true;
259
+ }
260
+ }
261
+ if (manifest.description === undefined) {
262
+ manifest.description = packageJson.description || "Made with Needle Engine";
263
+ if (manifest.description?.length)
264
+ modifiedManifest = true;
265
+ }
266
+
267
+ if (modifiedManifest) {
268
+ log("Write modified webmanifest to " + webmanifestPath);
269
+ const newManifest = JSON.stringify(manifest);
270
+ writeFileSync(webmanifestPath, newManifest, 'utf8');
271
+ }
272
+ }
273
+
274
+ /** Copies icons to the output directory
275
+ * If a start_url is provided, it will be used as the base path for relative icon paths
276
+ * @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
277
+ * @param {string} outDir
278
+ * @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
279
+ * @returns {boolean} */
280
+ function processIcons(manifest, outDir, context) {
281
+ let modified = false;
282
+
283
+ if (!manifest.icons?.length) {
284
+ // generate icons
285
+ generateIcons(manifest, context);
286
+ }
287
+
288
+ for (let i = 0; i < manifest.icons?.length; i++) {
289
+ try {
290
+ const icon = manifest.icons[i];
291
+ let src = icon.src;
292
+ const iconSrcIsAbsolute = src.startsWith("http://") || src.startsWith("https://");
293
+ const hasAbsoluteStartUrl = manifest.start_url?.startsWith("http://") || manifest.start_url?.startsWith("https://");
294
+ if (!iconSrcIsAbsolute && hasAbsoluteStartUrl) {
295
+ log("Making icon src relative to start_url", src);
296
+ icon.src = manifest.start_url + src;
297
+ modified = true;
298
+ }
299
+ }
300
+ catch (e) {
301
+ log("Error processing PWA icon[" + i + "]", e);
302
+ }
303
+ }
304
+ return modified;
305
+ }
306
+
307
+ /**
308
+ * @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
309
+ * @param {import("../types/index.d.ts").NeedlePWAProcessContext} context
310
+ */
311
+ function generateIcons(manifest, context) {
312
+ if (!manifest.icons) manifest.icons = [];
313
+ log("Generating PWA icons");
314
+ const sizes = [192, 512];
315
+ const defaultIconSVG = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 160 187.74"><defs><linearGradient id="a" x1="89.64" y1="184.81" x2="90.48" y2="21.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#62d399"/><stop offset=".51" stop-color="#acd842"/><stop offset=".9" stop-color="#d7db0a"/></linearGradient><linearGradient id="b" x1="69.68" y1="178.9" x2="68.08" y2="16.77" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0ba398"/><stop offset=".5" stop-color="#4ca352"/><stop offset="1" stop-color="#76a30a"/></linearGradient><linearGradient id="c" x1="36.6" y1="152.17" x2="34.7" y2="84.19" gradientUnits="userSpaceOnUse"><stop offset=".19" stop-color="#36a382"/><stop offset=".54" stop-color="#49a459"/><stop offset="1" stop-color="#76a30b"/></linearGradient><linearGradient id="d" x1="15.82" y1="153.24" x2="18" y2="90.86" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#267880"/><stop offset=".51" stop-color="#457a5c"/><stop offset="1" stop-color="#717516"/></linearGradient><linearGradient id="e" x1="135.08" y1="135.43" x2="148.93" y2="63.47" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#b0d939"/><stop offset="1" stop-color="#eadb04"/></linearGradient><linearGradient id="f" x1="-4163.25" y1="2285.12" x2="-4160.81" y2="2215.34" gradientTransform="rotate(20 4088.49 13316.712)" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#74af52"/><stop offset=".48" stop-color="#99be32"/><stop offset="1" stop-color="#c0c40a"/></linearGradient><symbol id="g" viewBox="0 0 160 187.74"><path style="fill:url(#a)" d="M79.32 36.98v150.76L95 174.54l6.59-156.31-22.27 18.75z"/><path style="fill:url(#b)" d="M79.32 36.98 57.05 18.23l6.59 156.31 15.68 13.2V36.98z"/><path style="fill:url(#c)" d="m25.19 104.83 8.63 49.04 12.5-14.95-2.46-56.42-18.67 22.33z"/><path style="fill:url(#d)" d="M25.19 104.83 0 90.24l16.97 53.86 16.85 9.77-8.63-49.04z"/><path style="fill:#9c3" d="M43.86 82.5 18.69 67.98 0 90.24l25.18 14.59L43.86 82.5z"/><path style="fill:url(#e)" d="m134.82 78.69-9.97 56.5 15.58-9.04L160 64.1l-25.18 14.59z"/><path style="fill:url(#f)" d="m134.82 78.69-18.68-22.33-2.86 65 11.57 13.83 9.97-56.5z"/><path style="fill:#ffe113" d="m160 64.1-18.69-22.26-25.17 14.52 18.67 22.33L160 64.1z"/><path style="fill:#f3e600" d="M101.59 18.23 79.32 0 57.05 18.23l22.27 18.75 22.27-18.75z"/></symbol></defs><use width="160" height="187.74" xlink:href="#g"/></svg>`;
316
+ const iconDir = process.cwd();
317
+ for (let size of sizes) {
318
+ const iconName = "pwa-icon-" + size + ".svg";
319
+ const iconPath = iconDir + "/" + iconName;
320
+ writeFileSync(iconPath, defaultIconSVG, 'utf8');
321
+ context.generatedFiles.push(iconPath);
322
+ const iconSrc = "./" + iconName;
323
+ log("Generated PWA icon", iconSrc);
324
+ const iconInfo = {
325
+ src: iconSrc,
326
+ type: "image/svg+xml",
327
+ sizes: size + "x" + size,
328
+ "purpose": "any"
329
+ };
330
+ manifest.icons.push(iconInfo);
331
+ manifest.icons.push({
332
+ ...iconInfo,
333
+ purpose: "maskable"
334
+ })
335
+ }
336
+ }
337
+
338
+ /** Tries to copy the icons to the output directory
339
+ */
340
+ function copyIcons(manifest, outDir) {
341
+ for (let i = 0; i < manifest.icons?.length; i++) {
342
+ try {
343
+ const icon = manifest.icons[i];
344
+ let src = icon.src;
345
+ // if the icon starts with the start url we remove it (to get the output folder relative URL for copying)
346
+ if (src.startsWith(manifest.start_url)) {
347
+ src = src.substring(manifest.start_url.length);
348
+ }
349
+ // check again if the src is absolute
350
+ const srcIsAbsolute = src.startsWith("http://") || src.startsWith("https://") || src.startsWith("//");
351
+ if (srcIsAbsolute) {
352
+ log("PWA icon src is absolute, not supported", icon.src);
353
+ continue;
354
+ }
355
+ // src is relative -> we copy the file to the output directory
356
+ const srcPath = process.cwd() + "/" + src;
357
+ if (existsSync(srcPath)) {
358
+ const targetPath = outDir + "/" + src;
359
+ // if the icon already exists in the output directory we skip it
360
+ if (existsSync(targetPath)) {
361
+ continue;
362
+ }
363
+ const outputDirectory = targetPath.substring(0, targetPath.lastIndexOf('/'));
364
+ if (!existsSync(outputDirectory)) {
365
+ mkdirSync(outputDirectory, { recursive: true });
366
+ }
367
+ log("Copy PWA icon " + src + " to output");
368
+ copyFileSync(srcPath, targetPath);
369
+ }
370
+ }
371
+ catch (e) {
372
+ log("Error processing PWA icon[" + i + "]", e);
373
+ }
374
+ }
375
+ }
376
+
377
+
378
+
379
+ /**
380
+ * Merges the current workbox config with the default workbox config
381
+ * @param {import("../types/webmanifest.d.ts").Webmanifest} manifest
382
+ */
383
+ function processWorkboxConfig(manifest) {
384
+
385
+ // Workaround: urlPattern, ignoreSearch und ignoreURLParametersMatching, dontCacheBustURLsMatching are because we currently append ?v=... to loaded GLB files
386
+
387
+ // this is our default config
388
+ const defaultWorkboxConfig = {
389
+ globPatterns: ['**'],
390
+ // glb files are large – we need to increase the cache size here.
391
+ // In practice we want to precache everything for offline usage,
392
+ // but we still want to get a warning beyond 50MB.
393
+ maximumFileSizeToCacheInBytes: 50000000,
394
+ dontCacheBustURLsMatching: /\.[a-f0-9]{8}\./,
395
+ ignoreURLParametersMatching: [/.*/],
396
+ runtimeCaching: [
397
+ {
398
+ urlPattern: ({ url }) => url,
399
+ // Apply a network-first strategy.
400
+ handler: 'NetworkFirst',
401
+ options: {
402
+ matchOptions: {
403
+ ignoreSearch: true,
404
+ },
405
+ }
406
+ },
407
+ ],
408
+ }
409
+
410
+ if (manifest.workboxConfig) {
411
+ manifest.workboxConfig = { ...defaultWorkboxConfig, ...manifest.workboxConfig };
412
+ return true;
413
+ }
414
+ else {
415
+ manifest.workboxConfig = defaultWorkboxConfig;
416
+ return true;
417
+ }
418
+ }
plugins/types/webmanifest.d.ts ADDED
@@ -0,0 +1,26 @@
1
+
2
+
3
+
4
+ declare type Icon = {
5
+ src: string;
6
+ sizes: string;
7
+ type: string;
8
+ purpose: string;
9
+ }
10
+
11
+ export declare type Webmanifest = {
12
+ start_url: string;
13
+ name?: string;
14
+ short_name?: string;
15
+ id?: string;
16
+ icons?: Array<Icon>;
17
+ workbox?: object;
18
+ }
19
+
20
+ export declare type NeedlePWAOptions = {
21
+
22
+ }
23
+
24
+ export declare type NeedlePWAProcessContext = {
25
+ generatedFiles: Array<string>;
26
+ }