13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/browser/index.ts:
--------------------------------------------------------------------------------
1 | import { createDataElement } from '../common/internalLogic';
2 |
3 | window.onload = function (): void {
4 | // Program starts here. Creates a sample graph in the
5 | // DOM node with the specified ID. This function is invoked
6 | // from the onLoad event handler of the document (see below).
7 | createDataElement(document.getElementById('container'), 'test foo');
8 | };
9 |
--------------------------------------------------------------------------------
/src/@types/assets/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.jpg' {
2 | const content: string;
3 | export default content;
4 | }
5 |
6 | declare module '*.png' {
7 | const content: string;
8 | export default content;
9 | }
10 |
11 | declare module '*.json' {
12 | const content: string;
13 | export default content;
14 | }
15 |
16 | declare module '*.svg' {
17 | const content: string;
18 | export default content;
19 | }
20 |
--------------------------------------------------------------------------------
/src/common/internalLogic.ts:
--------------------------------------------------------------------------------
1 | import { x } from './babelExample';
2 |
3 | /**
4 | * Example of a function that takes in as a parameter an container and renders some text into it
5 | * @param container element to render data into
6 | * @param value value to render
7 | * @returns the same container element
8 | */
9 | export function createDataElement(container: HTMLElement, value: string): HTMLElement {
10 | const t = x(1);
11 | container.innerText = value + t;
12 | return container;
13 | }
14 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: node:latest
2 |
3 |
4 | cache:
5 | paths:
6 | - node_modules/
7 |
8 | stages:
9 | - build
10 | - release
11 |
12 | Build:
13 | stage: build
14 | before_script:
15 | - mkdir dist
16 | - yarn install
17 | script:
18 | - yarn run build
19 | - cp zip/* dist
20 | - yarn run buildDev
21 | - cp zip/* dist
22 | artifacts:
23 | paths:
24 | - dist/*
25 |
26 | Publish:
27 | stage: release
28 | script:
29 | - npx semantic-release
30 | only:
31 | - release
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "sourceMap": true,
5 | "noImplicitAny": false,
6 | "module": "esnext",
7 | "moduleResolution": "node",
8 | "target": "es5",
9 | "experimentalDecorators": true,
10 | "typeRoots": [
11 | "./src/@types",
12 | "./node_modules/@types"
13 | ],
14 | "lib": [
15 | "es2015",
16 | "dom"
17 | ],
18 | },
19 | "include": [
20 | "./src/**/*"
21 | ]
22 | }
--------------------------------------------------------------------------------
/src/images/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "type": "npm",
8 | "script": "watch",
9 | "problemMatcher": [],
10 | "group": {
11 | "kind": "build",
12 | "isDefault": true
13 | }
14 | },
15 | {
16 | "type": "npm",
17 | "script": "server",
18 | "problemMatcher": []
19 | },
20 | {
21 | "type": "npm",
22 | "script": "build",
23 | "problemMatcher": []
24 | },
25 | {
26 | "type": "npm",
27 | "script": "packageExtension",
28 | "problemMatcher": []
29 | }
30 | ]
31 | }
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import { mergeWithCustomize, customizeObject } from 'webpack-merge';
2 | import { createConfig } from './webpack/webpack.common';
3 |
4 | module.exports = (env, argv) =>
5 | mergeWithCustomize({
6 | customizeObject: customizeObject({
7 | entry: 'replace',
8 | externals: 'replace',
9 | }),
10 | })(createConfig(env, argv), {
11 | entry: {
12 | // the entry point when viewing the index.html page
13 | htmlDemo: './src/browser/index.ts',
14 | // the entry point for the runtime widget
15 | widgetRuntime: `./src/runtime/index.ts`,
16 | // the entry point for the ide widget
17 | widgetIde: `./src/ide/index.ts`,
18 | },
19 | // moment is available directly on window inside the thingworx runtime and mashup builder
20 | externals: { moment: 'moment' },
21 | });
22 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches:
5 | - master
6 |
7 | permissions:
8 | contents: read # for checkout
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: write # to be able to publish a GitHub release
16 | issues: write # to be able to comment on released issues
17 | pull-requests: write # to be able to comment on released pull requests
18 | id-token: write # to enable use of OIDC for npm provenance
19 | steps:
20 | - name: Checkout
21 | uses: actions/checkout@v3
22 | with:
23 | fetch-depth: 0
24 | - name: Setup Node.js
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: "lts/*"
28 | - name: Install dependencies
29 | run: yarn install --frozen-lockfile
30 | - name: Release
31 | env:
32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 | run: npx semantic-release
--------------------------------------------------------------------------------
/.releaserc.json:
--------------------------------------------------------------------------------
1 | {
2 | "branches": [
3 | "release",
4 | "master"
5 | ],
6 | "plugins": [
7 | "@semantic-release/commit-analyzer",
8 | "@semantic-release/release-notes-generator",
9 | [
10 | "@semantic-release/changelog",
11 | {
12 | "changelogFile": "CHANGELOG.md"
13 | }
14 | ],
15 | [
16 | "@semantic-release/npm",
17 | {
18 | "npmPublish": false
19 | }
20 | ],
21 | [
22 | "@semantic-release/exec",
23 | {
24 | "publishCmd": "yarn prepublishOnly"
25 | }
26 | ],
27 | [
28 | "@semantic-release/git",
29 | {
30 | "assets": [
31 | "package.json",
32 | "CHANGELOG.md"
33 | ],
34 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
35 | }
36 | ],
37 | [
38 | "@semantic-release/github",
39 | {
40 | "assets": [
41 | {
42 | "path": "dist/*-prod-*.zip",
43 | "label": "Production version"
44 | },
45 | {
46 | "path": "dist/*-dev-*.zip",
47 | "label": "Development distribution"
48 | }
49 | ]
50 | }
51 | ]
52 | ]
53 | }
--------------------------------------------------------------------------------
/.releaserc-gitlab.json:
--------------------------------------------------------------------------------
1 | {
2 | "branches": [
3 | "release",
4 | "master"
5 | ],
6 | "plugins": [
7 | "@semantic-release/commit-analyzer",
8 | "@semantic-release/release-notes-generator",
9 | [
10 | "@semantic-release/changelog",
11 | {
12 | "changelogFile": "CHANGELOG.md"
13 | }
14 | ],
15 | [
16 | "@semantic-release/npm",
17 | {
18 | "npmPublish": false
19 | }
20 | ],
21 | [
22 | "@semantic-release/exec",
23 | {
24 | "publishCmd": "yarn prepublishOnly"
25 | }
26 | ],
27 | [
28 | "@semantic-release/git",
29 | {
30 | "assets": [
31 | "package.json",
32 | "CHANGELOG.md"
33 | ],
34 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
35 | }
36 | ],
37 | [
38 | "@semantic-release/gitlab",
39 | {
40 | "gitlabUrl": "https://gitlab.com",
41 | "assets": [
42 | {
43 | "path": "dist/*-prod-*.zip",
44 | "label": "Production version"
45 | },
46 | {
47 | "path": "dist/*-dev-*.zip",
48 | "label": "Development distribution"
49 | }
50 | ]
51 | }
52 | ]
53 | ]
54 | }
--------------------------------------------------------------------------------
/webpack/moduleSourceUrlUpdaterPlugin.ts:
--------------------------------------------------------------------------------
1 | import { Compilation, Compiler, WebpackPluginInstance, sources } from 'webpack';
2 |
3 | /**
4 | * Webpack plugin that walks through all the compilation generated files (chunks)
5 | * and updates their source url to match the specified context
6 | * Used because otherwise the developer can confuse between sourcemaps, especially on Chrome
7 | */
8 | export class ModuleSourceUrlUpdaterPlugin implements WebpackPluginInstance {
9 | options: { context: string };
10 |
11 | constructor(options: { context: string }) {
12 | this.options = options;
13 | }
14 |
15 | apply(compiler: Compiler) {
16 | const options = this.options;
17 | compiler.hooks.compilation.tap('ModuleSourceUrlUpdaterPlugin', (compilation) => {
18 | compilation.hooks.processAssets.tap(
19 | {
20 | name: 'ModuleSourceUrlUpdaterPlugin',
21 | stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
22 | },
23 | () => {
24 | for (const chunk of compilation.chunks) {
25 | for (const file of chunk.files) {
26 | compilation.updateAsset(file, (old) => {
27 | return new sources.RawSource(
28 | getAssetSourceContents(old).replace(
29 | /\/\/# sourceURL=webpack-internal:\/\/\//g,
30 | '//# sourceURL=webpack-internal:///' +
31 | options.context +
32 | '/',
33 | ),
34 | );
35 | });
36 | }
37 | }
38 | },
39 | );
40 | });
41 | }
42 | }
43 |
44 | /**
45 | * Returns the string representation of an assets source.
46 | *
47 | * @param source
48 | * @returns
49 | */
50 | export const getAssetSourceContents = (assetSource: sources.Source): string => {
51 | const source = assetSource.source();
52 | if (typeof source === 'string') {
53 | return source;
54 | }
55 |
56 | return source.toString();
57 | };
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo_thingworx_webpack_widget",
3 | "version": "1.4.1",
4 | "description": "Example of a widget built using typescript, babel and webpack",
5 | "packageName": "demoWebpackWidget",
6 | "author": "placatus@iqnox.com",
7 | "minimumThingWorxVersion": "6.0.0",
8 | "homepage": "https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget",
9 | "scripts": {
10 | "test": "echo \"Error: no test specified\" && exit 1",
11 | "lint:check": "eslint --ext ts,tsx src",
12 | "lint:fix": "eslint --ext ts,tsx --fix src",
13 | "build": "webpack --mode production",
14 | "buildDev": "webpack --mode development",
15 | "watch": "webpack --watch --mode development",
16 | "server": "webpack serve",
17 | "upload": "webpack --mode development --env upload",
18 | "prepublishOnly": "rm -rf dist && mkdir dist && yarn run build && mv zip/* dist && yarn run buildDev && mv zip/* dist"
19 | },
20 | "type": "module",
21 | "license": "ISC",
22 | "devDependencies": {
23 | "@babel/core": "^7.21.8",
24 | "@babel/preset-env": "^7.21.5",
25 | "@semantic-release/changelog": "^6.0.0",
26 | "@semantic-release/exec": "^6.0.1",
27 | "@semantic-release/git": "^10.0.0",
28 | "@semantic-release/gitlab": "^12.0.5",
29 | "@types/jquery": "^3.5.14",
30 | "@types/node": "^20.5.9",
31 | "@types/webpack-env": "^1.18.0",
32 | "@typescript-eslint/eslint-plugin": "^6.1.0",
33 | "@typescript-eslint/parser": "^6.1.0",
34 | "babel-loader": "^9.1.2",
35 | "clean-webpack-plugin": "^4.0.0",
36 | "copy-webpack-plugin": "^11.0.0",
37 | "css-loader": "^6.7.3",
38 | "cz-conventional-changelog": "^3.3.0",
39 | "dotenv": "^16.0.3",
40 | "dts-bundle-webpack": "^1.0.2",
41 | "esbuild-loader": "^4.0.2",
42 | "esbuild-register": "^3.4.2",
43 | "eslint": "^8.45.0",
44 | "eslint-config-prettier": "^9.0.0",
45 | "eslint-plugin-prettier": "^5.0.0",
46 | "eslint-plugin-react": "^7.32.2",
47 | "extract-loader": "^5.1.0",
48 | "file-loader": "^6.2.0",
49 | "fork-ts-checker-webpack-plugin": "^8.0.0",
50 | "prettier": "^3.0.0",
51 | "source-map-loader": "^4.0.1",
52 | "style-loader": "^3.3.2",
53 | "ts-declaration-webpack-plugin": "^1.2.3",
54 | "typescript": "^5.1.6",
55 | "webpack": "^5.82.0",
56 | "webpack-cli": "^5.0.2",
57 | "webpack-dev-server": "^4.13.3",
58 | "webpack-merge": "^5.3.0",
59 | "xml2js": "^0.6.2",
60 | "zip-webpack-plugin": "^4.0.1"
61 | },
62 | "dependencies": {
63 | "typescriptwebpacksupport": "^2.2.2"
64 | },
65 | "config": {
66 | "commitizen": {
67 | "path": "./node_modules/cz-conventional-changelog"
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser
3 | extends: [
4 | 'plugin:prettier/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
5 | 'plugin:@typescript-eslint/recommended-type-checked',
6 | 'plugin:@typescript-eslint/stylistic-type-checked',
7 | ],
8 | parserOptions: {
9 | ecmaFeatures: {
10 | jsx: true, // Allows for the parsing of JSX
11 | },
12 | project: true,
13 | tsconfigRootDir: __dirname,
14 | },
15 | plugins: ['prettier', '@typescript-eslint'],
16 | rules: {
17 | '@typescript-eslint/dot-notation': 'off',
18 | '@typescript-eslint/no-unsafe-member-access': 'warn',
19 | '@typescript-eslint/no-unsafe-return': 'warn',
20 | '@typescript-eslint/no-unsafe-assignment': 'warn',
21 | '@typescript-eslint/no-unsafe-call': 'error',
22 | '@typescript-eslint/no-unsafe-argument': 'warn',
23 | '@typescript-eslint/no-explicit-any': 'warn',
24 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'warn',
25 | '@typescript-eslint/prefer-nullish-coalescing': 'off',
26 | '@typescript-eslint/no-floating-promises': 'off',
27 | '@typescript-eslint/consistent-type-definitions': 'off',
28 | '@typescript-eslint/class-literal-property-style': 'off',
29 | '@typescript-eslint/no-misused-promises': [
30 | 'error',
31 | {
32 | checksVoidReturn: false,
33 | },
34 | ],
35 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
36 | '@typescript-eslint/explicit-function-return-type': 'off',
37 | '@typescript-eslint/no-var-requires': 'off',
38 | '@typescript-eslint/no-unused-vars': [
39 | 'warn',
40 | {
41 | argsIgnorePattern: '^_',
42 | varsIgnorePattern: '^_',
43 | caughtErrorsIgnorePattern: '^_',
44 | },
45 | ],
46 | // Deactivated because it also disables the use of readonly T[] over ReadonlyArray
47 | // and readonly T[] can be confusing (is T or the array readonly)
48 | '@typescript-eslint/array-type': 'off',
49 |
50 | // Disabled because this defeats one the purposes of template expressions, to make it easy to
51 | // log values which may be incorrect or mistyped
52 | '@typescript-eslint/restrict-template-expressions': 'off',
53 |
54 | // Disabled because this prevents passing around unbound functions in all cases, even when they
55 | // don't reference this in the implementation at all and the workaround of specifying this: void
56 | // as the first argument is very awkward
57 | '@typescript-eslint/unbound-method': 'off',
58 | },
59 | overrides: [
60 | {
61 | // enable the rule specifically for TypeScript files
62 | files: ['*.ts', '*.tsx'],
63 | rules: {
64 | '@typescript-eslint/explicit-function-return-type': [
65 | 'warn',
66 | { allowExpressions: true },
67 | ],
68 | '@typescript-eslint/no-var-requires': ['error'],
69 | },
70 | },
71 | ],
72 | settings: {
73 | react: {
74 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use
75 | },
76 | },
77 | ignorePatterns: '.eslintrc.cjs',
78 | };
79 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [1.4.1](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.4.0...v1.4.1) (2023-11-30)
2 |
3 |
4 | ### Bug Fixes
5 |
6 | * trigger a new release, with a production build ([2b998cf](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/2b998cf2ffb464d8f4f73e40651818c8a242df09))
7 |
8 | # [1.4.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.3.0...v1.4.0) (2023-09-07)
9 |
10 |
11 | ### Features
12 |
13 | * Update all widget dependencies, switch to configs in typescript, use esbuild for transforming code, improve eslint rules ([3e1cca3](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/3e1cca3b36650fb8fef14d3329aefc80cf215d1d))
14 |
15 | # [1.3.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.2.0...v1.3.0) (2022-08-22)
16 |
17 |
18 | ### Features
19 |
20 | * Generate automatic descriptions from javadoc for widget properties, services and classes ([#47](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/issues/47)) ([323fb21](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/323fb21271e1dd4dbeaf5cee105d45e2cabea2f9))
21 |
22 | # [1.2.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.1.2...v1.2.0) (2021-09-23)
23 |
24 |
25 | ### Bug Fixes
26 |
27 | * Update dependencies ([d0c6ab0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/d0c6ab0077397b044a40dbc96bf0165bed45d5d7))
28 |
29 |
30 | ### Features
31 |
32 | * Simplify webpack config by adopting the asset resources in webpack 5. Include an example for css in ide ([3059caa](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/3059caa682cdad7b7359f8857dcd35937fd7aedc))
33 |
34 | ## [1.1.2](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.1.1...v1.1.2) (2021-08-24)
35 |
36 |
37 | ### Bug Fixes
38 |
39 | * Resolve issue where the webpack dev server was using the incorrect public path ([700b9e8](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/700b9e861ddd0c38819c1c1d8f1382d7e3183e0e))
40 |
41 | ## [1.1.1](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.1.0...v1.1.1) (2021-06-25)
42 |
43 |
44 | ### Bug Fixes
45 |
46 | * Bump dependency versions ([7c8420b](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/7c8420bd063c898ce78da482c93eafefe469a4a7))
47 | * Improve documentation and provide additional links ([40eb014](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/40eb014db2eb8d06b103adbed3e2d0b90e4a3f6b))
48 |
49 | # [1.1.0](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.0.1...v1.1.0) (2020-11-01)
50 |
51 |
52 | ### Bug Fixes
53 |
54 | * **deps:** Added proepr dependency to semantic-release ([3511ae2](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/3511ae2c5b0912d7f3add5182fd691217be7cd8f))
55 |
56 |
57 | ### Features
58 |
59 | * Reorganized the naming of the default widget files, and remove the initialization step ([0a324fa](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/0a324fa89e402630c252a3e84f52ea69a51fc6e8))
60 |
61 | ## [1.0.1](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/compare/v1.0.0...v1.0.1) (2020-11-01)
62 |
63 |
64 | ### Bug Fixes
65 |
66 | * Fixed case sensitivity issue ([d093a6d](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/d093a6d694ff38114580c69b6405565da03630a6))
67 | * **build:** Remove the metadata.xml file and optimize the generator plugin ([417fd85](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/417fd8539663ff87ed25e80ec84e756d3b30fabf))
68 |
69 | # 1.0.0 (2020-11-01)
70 |
71 |
72 | ### Bug Fixes
73 |
74 | * **build:** Split the webpack build system into separate files, with the option of using webpack-merge for widget specific changes ([352d400](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/352d4006d325d2d1481e4c54ea5d93887ddf67a9))
75 | * **docs:** Added documentation about using semantic release and CI/CD pipelines ([bbdcb73](https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/commit/bbdcb73982c3ed8b639f06f9128d730f0ea707d6))
76 |
--------------------------------------------------------------------------------
/src/runtime/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TWWidgetDefinition,
3 | property,
4 | canBind,
5 | TWEvent,
6 | event,
7 | service,
8 | } from 'typescriptwebpacksupport/widgetRuntimeSupport';
9 |
10 | /**
11 | * The `@TWWidgetDefinition` decorator marks a class as a Thingworx widget. It can only be applied to classes
12 | * that inherit from the `TWRuntimeWidget` class.
13 | */
14 | @TWWidgetDefinition
15 | class DemoWebpackWidget extends TWRuntimeWidget {
16 | /**
17 | * The `@event` decorator can be applied to class member to mark them as events.
18 | * They must have the `TWEvent` type and can be invoked to trigger the associated event.
19 | *
20 | * Optionally, the decorator can receive the name of the event as its parameter; if it is not specified,
21 | * the name of the event will be considered to be the same as the name of the class member.
22 | */
23 | @event clicked: TWEvent;
24 |
25 | /**
26 | * The `@property` decorator can be applied to class member to mark them as events.
27 | * The value of the class member and of the associated widget property will be kept in sync.
28 | *
29 | * The runtime will also automatically update the value of the property for bindings; because of this
30 | * the `updateProperty` method becomes optional. If `updateProperty` is overriden, you must invoke
31 | * the superclass implementation to ensure that decorated properties are updated correctly.
32 | *
33 | * Optionally, the decorator can receive a number of aspects as its parameters.
34 | */
35 | @property clickMessage: string;
36 |
37 | /**
38 | * The `canBind` and `didBind` aspects can be used to specify callback methods to execute when the value of
39 | * the property is about to be updated or has been updated because of a binding.
40 | *
41 | * For `canBind`, the method can decide to reject the newly received value.
42 | */
43 | @property(canBind('valueWillBind')) set value(value: string) {
44 | this.internalLogic.createDataElement(this.jqElement[0], value);
45 | }
46 |
47 | /**
48 | * Optionally, the first parameter of the `@property` decorator can be a string that specifies the
49 | * name of the property as it is defined in the IDE class. This can be used to have different names
50 | * in the definition and implementation.
51 | */
52 | @property('clickedAmount') timesClicked: number;
53 |
54 | /**
55 | * This method is invoked whenever the `value` property is about to be updated because of a binding,
56 | * because it has been specified in the `canBind` aspect of the `value` property.
57 | * @param value Represents the property's new value.
58 | * @param info The complete updatePropertyInfo object.
59 | * @return `true` if the property should update to the new value, `false` otherwise.
60 | */
61 | valueWillBind(value: string, info: TWUpdatePropertyInfo): boolean {
62 | alert(`Value will be updated to ${value}`);
63 | return true;
64 | }
65 |
66 | /**
67 | * Invoked to obtain the HTML structure corresponding to the widget.
68 | * @return The HTML structure.
69 | */
70 | renderHtml(): string {
71 | return `
${this.value}
`;
72 | }
73 |
74 | internalLogic: typeof import('../common/internalLogic');
75 |
76 | /**
77 | * Invoked after the widget's HTML element has been created.
78 | * The `jqElement` property will reference the correct element within this method.
79 | */
80 | async afterRender(): Promise {
81 | this.internalLogic = await import('../common/internalLogic');
82 | this.jqElement[0].addEventListener('click', (event: MouseEvent): void => {
83 | this.timesClicked++;
84 | this.clicked();
85 | event.stopPropagation();
86 | });
87 | }
88 |
89 | /**
90 | * The `@service` decorator can be applied to methods to mark them as events.
91 | *
92 | * The runtime will also automatically call the method when the associated service is invoked.
93 | *
94 | * Optionally, the decorator can receive the name of the service as its parameter; if it is not specified,
95 | * the name of the service will be considered to be the same as the name of the method.
96 | */
97 | @service testService(): void {
98 | alert(this.clickMessage);
99 | }
100 |
101 | /**
102 | * Invoked when this widget is destroyed. This method should be used to clean up any resources created by the widget
103 | * that cannot be reclaimed by the garbage collector automatically (e.g. HTML elements added to the page outside of the widget's HTML element)
104 | */
105 | beforeDestroy?(): void {
106 | // add disposing logic
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/webpack/widgetMetadataGeneratorPlugin.ts:
--------------------------------------------------------------------------------
1 | import * as xml2js from 'xml2js';
2 | import { Compiler, WebpackPluginInstance, sources } from 'webpack';
3 |
4 | const XML_FILE_TEMPLATE = `
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | `;
15 |
16 | interface Options {
17 | packageName: string;
18 | packageJson: {
19 | description: string;
20 | author: string;
21 | minimumThingWorxVersion: string;
22 | version: string;
23 | autoUpdate: string;
24 | };
25 | }
26 |
27 | /**
28 | * Webpack plugin that generates the XML file representing the ThingWorx widget metadata
29 | * This build out the XML file based on data in parameters
30 | */
31 | export class WidgetMetadataGenerator implements WebpackPluginInstance {
32 | options: Options;
33 | constructor(options: Options) {
34 | this.options = options;
35 | }
36 |
37 | apply(compiler: Compiler) {
38 | compiler.hooks.compilation.tap('ModuleSourceUrlUpdaterPlugin', (compilation) => {
39 | compilation.hooks.additionalAssets.tap('WidgetMetadataGeneratorPlugin', () => {
40 | const options = this.options;
41 | // read the metadata xml file using xml2js
42 | // transform the metadata to json
43 | xml2js.parseString(XML_FILE_TEMPLATE, function (err, result) {
44 | if (err) console.log('Error parsing metadata file' + err);
45 | const extensionPackageAttributes =
46 | result.Entities.ExtensionPackages[0].ExtensionPackage[0].$;
47 | // set the name of the extension package
48 | extensionPackageAttributes.name = options.packageName;
49 | // set the description from the package.json
50 | extensionPackageAttributes.description = options.packageJson.description;
51 | // set the vendor using the author field in package json
52 | extensionPackageAttributes.vendor = options.packageJson.author;
53 | // set the minimum thingworx version
54 | extensionPackageAttributes.minimumThingWorxVersion =
55 | options.packageJson.minimumThingWorxVersion;
56 | // set the version of the package
57 | extensionPackageAttributes.packageVersion = options.packageJson.version;
58 | // set the name of the widget itself
59 | result.Entities.Widgets[0].Widget[0].$.name = options.packageName;
60 | if (options.packageJson.autoUpdate) {
61 | extensionPackageAttributes.buildNumber = JSON.stringify(
62 | options.packageJson.autoUpdate,
63 | );
64 | }
65 | // if there is no file resource set, then we must add a node in the xml
66 | if (!result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource) {
67 | result.Entities.Widgets[0].Widget[0].UIResources[0] = {};
68 | result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource = [];
69 | }
70 | // add the ide file
71 | result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource.push({
72 | $: {
73 | type: 'JS',
74 | file: `${options.packageName}.ide.bundle.js`,
75 | description: '',
76 | isDevelopment: 'true',
77 | isRuntime: 'false',
78 | },
79 | });
80 | // add the runtime file
81 | result.Entities.Widgets[0].Widget[0].UIResources[0].FileResource.push({
82 | $: {
83 | type: 'JS',
84 | file: `${options.packageName}.runtime.bundle.js`,
85 | description: '',
86 | isDevelopment: 'false',
87 | isRuntime: 'true',
88 | },
89 | });
90 | // transform the metadata back into xml
91 | const xml = new xml2js.Builder().buildObject(result);
92 |
93 | // insert the metadata xml as a file asset
94 | compilation.emitAsset('../../metadata.xml', new sources.RawSource(xml));
95 | });
96 | });
97 | });
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/webpack/uploadToThingworxPlugin.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import { TWClient } from './TWClient';
3 | import * as path from 'path';
4 | import { Compiler, WebpackPluginInstance } from 'webpack';
5 |
6 | interface Options {
7 | packageVersion: string;
8 | packageName: string;
9 | isProduction: boolean;
10 | }
11 |
12 | /**
13 | * Utility webpack ThingWorx plugin that is used to upload the widget
14 | * into the target ThingWorx server.
15 | * Uses native node fetch to run.
16 | */
17 | export class UploadToThingworxPlugin implements WebpackPluginInstance {
18 | options: Options;
19 | authorizationHeader: string;
20 |
21 | constructor(options: Options) {
22 | this.options = options;
23 | }
24 |
25 | apply(compiler: Compiler) {
26 | // this happens in the 'done' phase of the compilation so it will happen at the end
27 | compiler.hooks.afterDone.tap('UploadToThingworxPlugin', async () => {
28 | const extensionPath = path.join(
29 | process.cwd(),
30 | 'zip',
31 | `${this.options.packageName}-${this.options.isProduction ? 'prod' : 'dev'}-v${
32 | this.options.packageVersion
33 | }.zip`,
34 | );
35 | await this.uploadExtension(extensionPath, this.options.packageName);
36 | });
37 | }
38 |
39 | /**
40 | * Uploads an extension zip at the specified path to the thingworx server.
41 | * @param path The path to the zip file to upload.
42 | * @param name If specified, the name of the project that should appear in the console.
43 | * @returns A promise that resolves when the operation completes.
44 | */
45 | async uploadExtension(path: string, name?: string): Promise {
46 | process.stdout.write(
47 | `\x1b[2m❯\x1b[0m Uploading${name ? ` ${name}` : ''} to ${TWClient.server}`,
48 | );
49 |
50 | const formData = new FormData();
51 |
52 | formData.append(
53 | 'file',
54 | new Blob([fs.readFileSync(path)]),
55 | path.toString().split('/').pop(),
56 | );
57 |
58 | const response = await TWClient.importExtension(formData);
59 |
60 | if (response.statusCode != 200) {
61 | process.stdout.write(
62 | `\r\x1b[1;31m✖\x1b[0m Unable to upload${name ? ` ${name}` : ''} to ${
63 | TWClient.server
64 | }\n`,
65 | );
66 | throw new Error(
67 | `\x1b[1;31mFailed to upload project to thingworx with status code ${
68 | response.statusCode
69 | } (${response.statusMessage})\x1b[0m${this.formattedUploadStatus(response.body)}`,
70 | );
71 | }
72 |
73 | process.stdout.write(
74 | `\r\x1b[1;32m✔\x1b[0m Uploaded${name ? ` ${name}` : ''} to ${TWClient.server} \n`,
75 | );
76 | process.stdout.write(this.formattedUploadStatus(response.body));
77 | }
78 |
79 | /**
80 | * Returns a formatted string that contains the validation and installation status extracted
81 | * from the specified server response.
82 | * @param response The server response.
83 | * @returns The formatted upload status.
84 | */
85 | formattedUploadStatus(response): string {
86 | let infotable;
87 | let result = '';
88 | try {
89 | infotable = JSON.parse(response);
90 |
91 | // The upload response is an infotable with rows with two possible properties:
92 | // validate - an infotable where each row contains the validation result for each attempted extension
93 | // install - if validation passed, an infotable where each row contains the installation result for each attempted extension
94 | const validations = infotable.rows.filter((r) => r.validate);
95 | const installations = infotable.rows.filter((r) => r.install);
96 |
97 | const validation = validations.length && {
98 | rows: Array.prototype.concat.apply(
99 | [],
100 | validations.map((v) => v.validate.rows),
101 | ),
102 | };
103 | const installation = installations.length && {
104 | rows: Array.prototype.concat.apply(
105 | [],
106 | installations.map((i) => i.install.rows),
107 | ),
108 | };
109 |
110 | // A value of 1 for extensionReportStatus indicates failure, 2 indicates warning, and 0 indicates success
111 | for (const row of validation.rows) {
112 | if (row.extensionReportStatus == 1) {
113 | result += `🛑 \x1b[1;31mValidation failed\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`;
114 | } else if (row.extensionReportStatus == 2) {
115 | result += `🔶 \x1b[1;33mValidation warning\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`;
116 | } else {
117 | result += `✅ \x1b[1;32mValidation passed\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`;
118 | }
119 | }
120 |
121 | if (!installation) return result;
122 |
123 | // If an installation status is provided, display it as well; it has the same format as validation
124 | for (const row of installation.rows) {
125 | if (row.extensionReportStatus == 1) {
126 | result += `🛑 \x1b[1;31mInstallation failed\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`;
127 | } else if (row.extensionReportStatus == 2) {
128 | result += `🔶 \x1b[1;33mInstallation warning\x1b[0m for "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`;
129 | } else {
130 | result += `✅ \x1b[1;32mInstalled\x1b[0m "${row.extensionPackage.rows[0].name}-${row.extensionPackage.rows[0].packageVersion}": "${row.reportMessage}"\n`;
131 | }
132 | }
133 |
134 | return result;
135 | } catch (e) {
136 | // If the response isn't a parsable response, it is most likely a simple message
137 | // that may be printed directly.
138 | return response;
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/ide/index.ts:
--------------------------------------------------------------------------------
1 | // automatically import the css file
2 | import './ide.css';
3 | import {
4 | TWWidgetDefinition,
5 | autoResizable,
6 | description,
7 | property,
8 | defaultValue,
9 | bindingTarget,
10 | service,
11 | event,
12 | bindingSource,
13 | nonEditable,
14 | willSet,
15 | didSet,
16 | displayName,
17 | } from 'typescriptwebpacksupport/widgetIDESupport';
18 |
19 | import widgetIconUrl from '../images/icon.svg';
20 |
21 | /**
22 | * The `@TWWidgetDefinition` decorator marks a class as a Thingworx widget. It can only be applied to classes
23 | * that inherit from the `TWComposerWidget` class. It must receive the display name of the widget as its first parameter.
24 | * Afterwards any number of widget aspects may be specified.
25 | *
26 | * Because of this, the `widgetProperties` method is now optional. If overriden, you must invoke the superclass
27 | * implementation to ensure that decorated aspects are initialized correctly.
28 | */
29 | @description('A widget')
30 | @TWWidgetDefinition('Test', autoResizable)
31 | class DemoWebpackWidget extends TWComposerWidget {
32 | /**
33 | * The `@property` decorator can be applied to class members to mark them as widget properties.
34 | * This must be applied with the base type of the property as its first parameter.
35 | * The decorator can then also receive a series of aspects to apply to that properties as parameters.
36 | *
37 | * Because of this, the `widgetProperties` method is now optional. If overriden, you must invoke the superclass
38 | * implementation to ensure that decorated properties are initialized correctly.
39 | */
40 | @property('NUMBER', defaultValue(90)) width: number;
41 |
42 | /**
43 | * When the `@description` decorator is not used, the JSDoc documentation will be used as
44 | * the description for the property.
45 | */
46 | @property('NUMBER', defaultValue(30)) height: number;
47 |
48 | /**
49 | * A number of aspects such as `willSet`, `didSet` and `didBind` can be used to specify various callback
50 | * that will be invoked when the value of the property is bound or updated by the user through using the composer.
51 | */
52 | @description('A label to display on the widget.')
53 | @property(
54 | 'STRING',
55 | bindingTarget,
56 | defaultValue('my value'),
57 | willSet('valueWillSet'),
58 | didSet('valueDidSet'),
59 | )
60 | value: string;
61 |
62 | /**
63 | * The `@description` decorator can be applied before widget definitions and property, event or service decorators to specify
64 | * the description of the decorated class member. That description will appear in the composer.
65 | *
66 | * A `displayName` aspect may be used to give the property a friendly name with which it will be rendered in the properties
67 | * pane in the mashup builder.
68 | */
69 | @description('Tracks how many times the widget was clicked')
70 | @property('NUMBER', bindingSource, nonEditable, displayName('Clicked Amount'))
71 | clickedAmount: number;
72 |
73 | /**
74 | * Invoked to obtain the URL to this widget's icon.
75 | * @return The URL.
76 | */
77 | widgetIconUrl(): string {
78 | return widgetIconUrl;
79 | }
80 |
81 | /**
82 | * Invoked to obtain the HTML structure corresponding to the widget.
83 | * @return The HTML structure.
84 | */
85 | renderHtml(): string {
86 | return `
${this.value}
`;
87 | }
88 |
89 | /**
90 | * This method is invoked whenever the `value` property is about to be updated for any reason, either through
91 | * direct assignment or because the user has edited its value in the composer,
92 | * because it has been specified in the `willSet` aspect of the `value` property.
93 | *
94 | * Because of this, the `beforeSetProperty` method is now optional. If overriden, you must invoke the superclass
95 | * implementation to ensure that decorated properties are updated correctly.
96 | *
97 | * @param value Represents the property's new value.
98 | * @return A string, if the new value should be rejected, in which case the returned string will be
99 | * displayed as an error message to the user. If the value should be accepted, this method should return nothing.
100 | */
101 | valueWillSet(value: string): string | void {
102 | if (value == 'test') return 'Invalid value specified';
103 | }
104 |
105 | /**
106 | * This method is invoked whenever the `value` property has been updated for any reason, either through
107 | * direct assignment or because the user has edited its value in the composer,
108 | * because it has been specified in the `didSet` aspect of the `value` property.
109 | *
110 | * Because of this, the `afterSetProperty` method is now optional. If overriden, you must invoke the superclass
111 | * implementation to ensure that decorated properties are handled correctly.
112 | *
113 | * @param value Represents the property's new value.
114 | * @return `true` if the widget should be redrawn because of this change, nothing or `false` otherwise.
115 | */
116 | valueDidSet(value: string): boolean | void {
117 | this.jqElement[0].innerText = value;
118 | }
119 |
120 | /**
121 | * The service decorator defines a service.
122 | *
123 | * Because of this, the `widgetServices` method is now optional. If overriden, you must invoke the superclass
124 | * implementation to ensure that decorated services are initialized correctly.
125 | */
126 | @description('Prints out a message when invoked')
127 | @service
128 | testService;
129 |
130 | /**
131 | * Property declarations can be mixed in with other method and event or service declarations.
132 | */
133 | @description('A message to display when the widget is clicked.')
134 | @property('STRING', bindingTarget, defaultValue('Invoked via decorator'))
135 | clickMessage: string;
136 |
137 | /**
138 | * The event decorator defines an event.
139 | *
140 | * Because of this, the `widgetEvents` method is now optional. If overriden, you must invoke the superclass
141 | * implementation to ensure that decorated events are initialized correctly.
142 | */
143 | @description('Triggered when the widget is clicked')
144 | @event
145 | clicked;
146 |
147 | /**
148 | * Invoked after the widget's HTML element has been created.
149 | * The `jqElement` property will reference the correct element within this method.
150 | */
151 | afterRender(): void {
152 | // add after logic render here
153 | }
154 |
155 | /**
156 | * Invoked when this widget is destroyed. This method should be used to clean up any resources created by the widget
157 | * that cannot be reclaimed by the garbage collector automatically (e.g. HTML elements added to the page outside of the widget's HTML element)
158 | */
159 | beforeDestroy(): void {
160 | // add dispose logic here
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/webpack/webpack.common.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import * as fs from 'fs';
3 | import CopyWebpackPlugin from 'copy-webpack-plugin';
4 | // enable cleaning of the build and zip directories
5 | import { CleanWebpackPlugin } from 'clean-webpack-plugin';
6 | // enable building of the widget
7 | import ZipPlugin from 'zip-webpack-plugin';
8 | // enable reading master data from the package.json file
9 | // note that this is relative to the working directory, not to this file
10 | // import the extra plugins
11 | import { UploadToThingworxPlugin } from './uploadToThingworxPlugin';
12 | import { WidgetMetadataGenerator } from './widgetMetadataGeneratorPlugin';
13 | import { ModuleSourceUrlUpdaterPlugin } from './moduleSourceUrlUpdaterPlugin';
14 | import webpack from 'webpack';
15 | import { EsbuildPlugin } from 'esbuild-loader';
16 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
17 | import { WebpackConfiguration } from 'webpack-dev-server';
18 |
19 | const packageJson = JSON.parse(fs.readFileSync('./package.json', { encoding: 'utf-8' }));
20 |
21 | export function createConfig(env, argv): WebpackConfiguration {
22 | // look if we are in initialization mode based on the --upload argument
23 | const uploadEnabled = env ? env.upload : false;
24 | const packageName = packageJson.packageName || `${packageJson.name}_ExtensionPackage`;
25 | require('dotenv').config({ path: '.env' });
26 |
27 | // look if we are in production or not based on the mode we are running in
28 | const isProduction = argv.mode == 'production';
29 | const result = {
30 | entry: {
31 | // the entry point when viewing the index.html page
32 | htmlDemo: './src/browser/index.ts',
33 | // the entry point for the runtime widget
34 | widgetRuntime: `./src/runtime/index.ts`,
35 | // the entry point for the ide widget
36 | widgetIde: `./src/ide/index.ts`,
37 | },
38 | devServer: {
39 | port: 9011,
40 | },
41 | output: {
42 | path: path.join(process.cwd(), 'build', 'ui', packageName),
43 | filename: '[name].bundle.js',
44 | chunkFilename: '[id].chunk.js',
45 | chunkLoadingGlobal: `webpackJsonp${packageName}`,
46 | // this is the path when viewing the widget in thingworx
47 | publicPath: `../Common/extensions/${packageName}/ui/${packageName}/`,
48 | devtoolNamespace: packageName,
49 | },
50 | plugins: [
51 | new ForkTsCheckerWebpackPlugin(),
52 | new webpack.DefinePlugin({
53 | VERSION: JSON.stringify(packageJson.version),
54 | }),
55 | // delete build and zip folders
56 | new CleanWebpackPlugin({
57 | cleanOnceBeforeBuildPatterns: [path.resolve('build/**'), path.resolve('zip/**')],
58 | }),
59 | new CopyWebpackPlugin({
60 | patterns: [
61 | // in case we just want to copy some resources directly to the widget package, then do it here
62 | { from: 'src/static', to: 'static', noErrorOnMissing: true },
63 | // in case the extension contains entities, copy them as well
64 | { from: 'Entities/**/*.xml', to: '../../', noErrorOnMissing: true },
65 | // Include the license document in the package
66 | { from: 'LICENSE.MD', to: '../../LICENSE.MD', noErrorOnMissing: true },
67 | // Include the customer facing changelog in the package
68 | { from: 'CHANGELOG.MD', to: '../../CHANGELOG.MD', noErrorOnMissing: true },
69 | // Include a customer facing README.MD file with general information
70 | { from: 'README_EXTERNAL.MD', to: '../../README.MD', noErrorOnMissing: true },
71 | ],
72 | }),
73 | // generates the metadata xml file and adds it to the archive
74 | new WidgetMetadataGenerator({ packageName, packageJson }),
75 | // create the extension zip
76 | new ZipPlugin({
77 | path: path.join(process.cwd(), 'zip'), // a top level directory called zip
78 | pathPrefix: `ui/${packageName}/`, // path to the extension source code
79 | filename: `${packageName}-${isProduction ? 'prod' : 'dev'}-v${
80 | packageJson.version
81 | }.zip`,
82 | pathMapper: (assetPath) => {
83 | // handles renaming of the bundles
84 | if (assetPath == 'widgetRuntime.bundle.js') {
85 | return packageName + '.runtime.bundle.js';
86 | } else if (assetPath == 'widgetIde.bundle.js') {
87 | return packageName + '.ide.bundle.js';
88 | } else {
89 | return assetPath;
90 | }
91 | },
92 | exclude: [/htmlDemo/, isProduction ? /(.*)\.map$/ : /a^/],
93 | }),
94 | ],
95 | // if we are in development mode, then use "eval-source-map".
96 | // See https://webpack.js.org/configuration/devtool/ for all available options
97 | devtool: isProduction ? undefined : 'eval-source-map',
98 | resolve: {
99 | // Add '.ts' and '.tsx' as resolvable extensions.
100 | extensions: ['.ts', '.tsx', '.js', '.json'],
101 | },
102 | // enable a filesystem cache to speed up individual upload commands
103 | cache: {
104 | type: 'filesystem',
105 | compression: 'gzip',
106 | },
107 | module: {
108 | rules: [
109 | {
110 | // Match js, jsx, ts & tsx files
111 | test: /\.[jt]sx?$/,
112 | loader: 'esbuild-loader',
113 | },
114 | {
115 | test: /\.scss$/,
116 | use: [
117 | {
118 | loader: 'style-loader',
119 | options: {
120 | attributes: {
121 | 'data-description': `Styles for widget ${packageName}`,
122 | },
123 | },
124 | },
125 | 'css-loader',
126 | 'sass-loader',
127 | ],
128 | },
129 | {
130 | test: /\.css$/,
131 | use: [
132 | {
133 | loader: 'style-loader',
134 | options: {
135 | attributes: {
136 | 'data-description': `Styles for widget ${packageName}`,
137 | },
138 | },
139 | },
140 | 'css-loader',
141 | {
142 | loader: 'esbuild-loader',
143 | options: {
144 | minify: true,
145 | },
146 | },
147 | ],
148 | },
149 | {
150 | test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2|xml)$/i,
151 | // More information here https://webpack.js.org/guides/asset-modules/
152 | type: 'asset',
153 | },
154 | ],
155 | },
156 | } satisfies WebpackConfiguration;
157 | // if we are in production, enable the minimizer
158 | if (isProduction) {
159 | (result as WebpackConfiguration).optimization = {
160 | minimizer: [
161 | new EsbuildPlugin({
162 | minifyWhitespace: true,
163 | minifySyntax: true,
164 | // identifiers are left unminified because thingworx depends on the variable names for the widget names
165 | minifyIdentifiers: false,
166 | target: 'es2015',
167 | }),
168 | ],
169 | };
170 | } else {
171 | // this handles nice debugging on chromium
172 | result.plugins.push(
173 | new ModuleSourceUrlUpdaterPlugin({
174 | context: packageName,
175 | }),
176 | );
177 | }
178 | // if the upload is enabled, then add the uploadToThingworxPlugin with the credentials from package.json
179 | if (uploadEnabled) {
180 | result.plugins.push(
181 | new UploadToThingworxPlugin({
182 | thingworxServer: process.env.TARGET_THINGWORX_SERVER ?? '',
183 | thingworxUser: process.env.TARGET_THINGWORX_USER ?? '',
184 | thingworxPassword: process.env.TARGET_THINGWORX_PASSWORD ?? '',
185 | packageVersion: packageJson.version,
186 | packageName: packageName,
187 | isProduction: isProduction,
188 | }),
189 | );
190 | }
191 |
192 | return result;
193 | }
194 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Starter kit of a ThingWorx widget using Webpack and TypeScript
2 |
3 | This repository contains an example that can act as a starter kit for building widget using modern web development techniques like [Webpack](https://webpack.js.org/) and [TypeScript](https://www.typescriptlang.org/) and [babel](https://babeljs.io/).
4 |
5 | ## Why use it
6 |
7 | There are many advantages to this, and here's some of them:
8 |
9 | ### Webpack advantages
10 |
11 | * Because of *dynamic imports and require calls*, webpack can load javascript files and other resources only they are needed. So, if you are using a very big external library, this library is only loaded if the widget is used in a mashup, rather than being always loaded in the `CombinedExtensions.js` files.
12 | * *Better resource management*: You can load a resource (like images, xml files, css) only if it's used in the widget, and you'll no longer have to care about where to put it and how to package it. Also, the webpack loader will inline small files for better network performance.
13 | * *Automatic dependency management*: Webpack can be very easily integrated with the `npm` repositories, so this brings automatic dependency management. You'll no longer have to manually download libraries from the web, struggle to add all the dependencies and so on. Instead, `npm`, which functions similarly to maven central, handles all of this.
14 | * *Easily develop and test the widget outside of ThingWorx*: By doing the initial development and testing in a simple html page, it reduces the waiting times of publishing widget, doing reloads, etc...
15 | * *Allows using of different loaders*: You can develop your code in multiple languages, and use transpilers to convert you code javascript code that works in older javascript version. One of this languages, is typescript.
16 |
17 | ### Typescript
18 |
19 | * Typescript is a superscript of javascript with types.
20 | * Optional static typing (the key here is optional).
21 | * Type Inference, which gives some of the benefits of types, without actually using them.
22 | * Access to ES6 and ES7 features, before they become supported by major browsers.
23 | * The ability to compile down to a version of JavaScript that runs on all browsers.
24 | * Great tooling support with auto-completion..
25 |
26 | ## Using the widget template
27 |
28 | ### Required software
29 |
30 | The following software is required:
31 |
32 | * [NodeJS](https://nodejs.org/en/) needs to be installed and added to the `PATH`. You should use the LTS version.
33 |
34 | The following software is recommended:
35 |
36 | * [Visual Studio Code](https://code.visualstudio.com/): An integrated developer environment with great typescript support. You can also use any IDE of your liking, it just that most of the testing was done using VSCode.
37 |
38 | ### Proposed folder structure
39 |
40 | ```
41 | demoWebpackTypescriptWidget
42 | │ README.md // this file
43 | │ package.json // here you specify project name, homepage and dependencies. This is the only file you should edit to start a new project
44 | │ tsconfig.json // configuration for the typescript compiler
45 | │ webpack.config.js // configuration for webpack. Can be updated through the use of webpack-merge
46 | │ index.html // when testing the widget outside of ThingWorx, the index file used.
47 | │ .env.sample // sample file of how to declare the target ThingWorx server for automatic widget upload. Rename this file to .env
48 | │ .eslintrc.js // eslint configuration for automatic file formatting
49 | │ .releaserc.json // semantic-release sample configuration for publishing to GitHub Releases
50 | │ .releaserc-gitlab.json // semantic-release sample configuration for publishing to GitLab Releases
51 | └───webpack // Internal webpack configuration and plugins
52 | └───Entities // ThingWorx XML entities that are part of the widget. This can be Things, StyleDefinitions, etc. They can be exported using the SourceControl export functionality in ThingWorx.
53 | └───src // main folder where your development will take place
54 | │ └───browser // If the widget is developed from an external library, or you want to be able to test it outside of thingworx
55 | │ │ │ index.ts // Typescript file containing the necessary code to run the widget outside of thingworx, inside a browser
56 | │ └───ide // Folder containing the code that is used in the Mashup Builder
57 | │ │ │ index.ts // Contains the widget definition (properties, events, etc) used in the Mashup Builder
58 | │ └───runtime // Folder containing the code that is used during Mashup Runtime.
59 | │ │ │ index.ts // Main file with the widget runtime logic, (eg. what happens when the a property changes)
60 | │ └───common // Code that is shared across runtime, browser and ide
61 | │ │ │ file1.ts // typescript file with internal logic
62 | │ │ │ file2.js // javascript file in ES2015 with module
63 | │ │ │ ...
64 | │ └───styles // folder for css styles that you can import into your app using import statements. This can also be within each scope (browser, ide, runtime)
65 | │ └───images // folder for image resources you are statically including using import statements
66 | │ └───static // folder for resources that are copied over to the development extension. Think of folder of images that you reference only dynamically
67 | └───build // temporary folder used during compilation
68 | └───zip // location of the built extension
69 | ```
70 |
71 | ### Developing a new widget
72 |
73 | In order to start developing a new widget using this template you need to do the following:
74 |
75 | 1. Clone this repository
76 | ```
77 | git clone https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget
78 | ```
79 | Alternatively, you can also use the link https://github.com/ptc-iot-sharing/ThingworxDemoWebpackWidget/ to directly generate a new GitHub repository using this template.
80 | 2. Open `package.json` and configure the `name`, `description`, and other fields you find relevant
81 | 3. Run `yarn install`. This will install the development dependencies for this project.
82 | 4. Start working on your widget.
83 |
84 | ### Adding dependencies
85 |
86 | Dependencies can be added from [npm](https://www.npmjs.com/), using the `yarn add DEPENDENCY_NAME` command, or by adding them directly to `package.json`, under `dependencies`. After adding them to `package.json`, you should run `yarn install`.
87 | If you are using a javascript library that also has typescript mappings you can install those using `yarn add @types/DEPENDENCY_NAME`.
88 |
89 | ### Building and publishing
90 |
91 | The following commands allow you to build and compile your widget:
92 |
93 | * `yarn run build`: builds the production version of the widget. Creates a new extension zip file under the `zip` folder. The production version is optimized for sharing and using in production environments.
94 | * `yarn run upload`: creates a build, and uploads the extension zip to the ThingWorx server configured in `.env`. The build is created for development, with source-maps enabled.
95 | * `yarn run buildDev`: builds the development version of the widget. Creates a new extension zip file under the `zip` folder.The build is created for development, with source-maps enabled.
96 |
97 | ## Example of widgets that use this starter kit
98 |
99 | * [SVGViewer](https://github.com/ptc-iot-sharing/SvgViewerWidgetTWX): Allows viewing, interacting and manipulating SVG files in ThingWorx. Contains examples of using external libraries.
100 | * [Calendar](https://github.com/ptc-iot-sharing/CalendarWidgetTWX): A calendar widget for ThingWorx built using the fullcalendar library. Contains examples of using external libraries as well as referencing global external libraries without including them in the built package.
101 | * [BarcodeScanner](https://github.com/ptc-iot-sharing/ThingworxBarcodeScannerWidget): A client side widget for scanning barcodes.
102 | ## Sematic release
103 |
104 | The widget uses [semantic-release](https://semantic-release.gitbook.io/) and GitLab CI/CD pipelines for automatic version management and package publishing. This automates the whole widget release workflow including: determining the next version number, generating the release notes, updating the _CHANGELOG.MD_ and publishing a release. Please read through the *semantic-release* official documentation to better understand how it works.
105 |
106 | Because we are using *semantic-release* the commit messages must follow a specific format called [Angular commit conventions or Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). It is mandatory that this is followed. To help with this, the project is also setup with [commitizen](https://commitizen.github.io/cz-cli/) as a dev-dependency. So, you can use `git cz` instead of `git commit` to create a new commit.
107 |
108 | The repository should have one protected branch:
109 |
110 | * **master**: This is where the main development takes place. Each push to master is automatically built by the CI/CD pipeline and artifacts are generated.
111 |
112 | By default, this repository comes with two samples CI/CD configurations for both GitHub and GitLab. By default, the `.releaserc.json` triggers a GitHub release. If you want to use GitLab, rename `.releaserc-gitlab.json` to `.releaserc.json`.
113 |
114 | ## Support
115 |
116 | Feel free to open an issue if you encounter any problem while using this starter kit.
117 |
118 | Consulting and custom development can also be supported by contacting me at placatus@iqnox.com.
119 |
120 |
121 | # Disclaimer
122 | By downloading this software, the user acknowledges that it is unsupported, not reviewed for security purposes, and that the user assumes all risk for running it.
123 |
124 | Users accept all risk whatsoever regarding the security of the code they download.
125 |
126 | This software is not an official PTC product and is not officially supported by PTC.
127 |
128 | PTC is not responsible for any maintenance for this software.
129 |
130 | PTC will not accept technical support cases logged related to this Software.
131 |
132 | This source code is offered freely and AS IS without any warranty.
133 |
134 | The author of this code cannot be held accountable for the well-functioning of it.
135 |
136 | The author shared the code that worked at a specific moment in time using specific versions of PTC products at that time, without the intention to make the code compliant with past, current or future versions of those PTC products.
137 |
138 | The author has not committed to maintain this code and he may not be bound to maintain or fix it.
139 |
140 |
141 | # License
142 | I accept the MIT License (https://opensource.org/licenses/MIT) and agree that any software downloaded/utilized will be in compliance with that Agreement. However, despite anything to the contrary in the License Agreement, I agree as follows:
143 |
144 | I acknowledge that I am not entitled to support assistance with respect to the software, and PTC will have no obligation to maintain the software or provide bug fixes or security patches or new releases.
145 |
146 | The software is provided “As Is” and with no warranty, indemnitees or guarantees whatsoever, and PTC will have no liability whatsoever with respect to the software, including with respect to any intellectual property infringement claims or security incidents or data loss.
147 |
148 |
--------------------------------------------------------------------------------
/webpack/transformers/descriptionTransformer.js:
--------------------------------------------------------------------------------
1 | const ts = require('typescript');
2 |
3 | /**
4 | * The name of the decorator that identifies a widget class.
5 | */
6 | const WIDGET_CLASS_DECORATOR = 'TWWidgetDefinition';
7 |
8 | /**
9 | * The name of the decorator that identifies a property.
10 | */
11 | const WIDGET_PROPERRTY_DECORATOR = 'property';
12 |
13 | /**
14 | * The name of the decorator that identifies an event.
15 | */
16 | const WIDGET_EVENT_DECORATOR = 'event';
17 |
18 | /**
19 | * The name of the decorator that identifies a service.
20 | */
21 | const WIDGET_SERVICE_DECORATOR = 'service';
22 |
23 | /**
24 | * The name of the decorator that supplioes descriptions.
25 | */
26 | const DESCRIPTION_DECORATOR = 'description';
27 |
28 | /**
29 | * A typescript transformer that automatically generates description decorators from JSDoc tags.
30 | * This transformer must be used in the `before` phase.
31 | *
32 | * When used, a description decorator will be generated for a property or method that:
33 | * - has either the `@property`, `@event` or `@service` decorator applied to it
34 | * - is declared in a class that has the `@TWWidgetDefinition` decorator applied to it
35 | *
36 | * It will also generate a description decorator for any class that has the `@TWWidgetDefinition` decorator applied to it.
37 | *
38 | * If a description decorator is already specified for an element, the transformer will skip creating an additional
39 | * description decorator for that element.
40 | *
41 | * The transformer will take the text of the first JSDoc tag that refers to each matching element and
42 | * supply it as the argument for the description decorator.
43 | */
44 | class DescriptionTransformer {
45 | /**
46 | *
47 | * @param {ts.TransformationContext} context The transformation context.
48 | */
49 | constructor(context) {
50 | this.context = context;
51 | }
52 |
53 | /**
54 | * @type {ts.TransformationContext} The transformation context.
55 | */
56 | context;
57 |
58 | /**
59 | * Set to `true` if the file processed by this transformer is an IDE file.
60 | */
61 | isIDEFile = false;
62 |
63 | /**
64 | * Checks whether the given node has a decorator or decorator factory with the given name.
65 | * @param {string} name The name of the decorator to find.
66 | * @param {ts.Node} node The node in which to search.
67 | * @return {boolean} `true` if the decorator was found, `false` otherwise.
68 | */
69 | hasDecoratorNamed(name, node) {
70 | if (!node.decorators) return false;
71 |
72 | // Getting the decorator name depends on whether the decorator is applied directly or via a
73 | // decorator factory.
74 | for (const decorator of node.decorators) {
75 | // In a decorator factory, the decorator itself is the result of invoking
76 | // the decorator factory function so it doesn't technically have a name; in this case the name
77 | // of the decorator factory function is considered to be the decorator name.
78 | if (decorator.expression.kind == ts.SyntaxKind.CallExpression) {
79 | /** @type {ts.CallExpression} */ const callExpression = decorator.expression;
80 | if (
81 | callExpression.expression.kind == ts.SyntaxKind.Identifier &&
82 | callExpression.expression.text == name
83 | ) {
84 | return true;
85 | }
86 | } else if (decorator.expression.kind == ts.SyntaxKind.Identifier) {
87 | /** @type {ts.Identifier} */ const identifierExpression = decorator.expression;
88 | if (identifierExpression.text == name) {
89 | return true;
90 | }
91 | }
92 | }
93 | return false;
94 | }
95 |
96 | /**
97 | * Visits the given node. This method will be invoked for all nodes in the file.
98 | * @param {ts.Node} node The node to visit.
99 | * @return {ts.Node} The visited node, or a new node that will replace it.
100 | */
101 | visit(node) {
102 | // The description decorator only makes sense for IDE files
103 | // An IDE file is identified from its imports - it must import its Thingworx specific
104 | // decorators from the widgetIDESupport package
105 | if (node.kind == ts.SyntaxKind.ImportDeclaration) {
106 | /** @type {ts.ImportDeclaration} */ const importNode = node;
107 |
108 | /** @type {ts.StringLiteral} */ const module = importNode.moduleSpecifier;
109 | if (module.text == 'typescriptwebpacksupport/widgetIDESupport') {
110 | this.isIDEFile = true;
111 | }
112 | }
113 |
114 | // There are three kinds of nodes that are relevant to this transformer that will be handled here.
115 |
116 | // The first kind is a class declaration node
117 | if (node.kind == ts.SyntaxKind.ClassDeclaration && this.isIDEFile) {
118 | // Classes must have a `@TWWidgetDefinition` decorator and must not have a `@description` decorator in order to be considered
119 | if (
120 | this.hasDecoratorNamed(WIDGET_CLASS_DECORATOR, node) &&
121 | !this.hasDecoratorNamed(DESCRIPTION_DECORATOR, node)
122 | ) {
123 | // First visit the class members
124 | const replacementNode = ts.visitEachChild(
125 | node,
126 | (node) => this.visit(node),
127 | this.context,
128 | );
129 |
130 | // Then return a replacement
131 | const classNode = this.addDescriptionDecoratorToNode(replacementNode, node);
132 |
133 | return classNode;
134 | }
135 | }
136 | // The second kind is a property declaration node
137 | else if (node.kind == ts.SyntaxKind.PropertyDeclaration && this.isIDEFile) {
138 | // Members must be part of a class that has the `@TWWidgetDefinition` decorator
139 | // and must not have the `@description` decorator themselves
140 | if (
141 | node.parent.kind == ts.SyntaxKind.ClassDeclaration &&
142 | (this.hasDecoratorNamed(WIDGET_PROPERRTY_DECORATOR, node) ||
143 | this.hasDecoratorNamed(WIDGET_EVENT_DECORATOR, node) ||
144 | this.hasDecoratorNamed(WIDGET_SERVICE_DECORATOR, node))
145 | ) {
146 | if (!this.hasDecoratorNamed(DESCRIPTION_DECORATOR, node)) {
147 | return this.addDescriptionDecoratorToNode(node);
148 | }
149 | }
150 | }
151 | // The final kind is a method declaration node
152 | else if (node.kind == ts.SyntaxKind.MethodDeclaration && this.isIDEFile) {
153 | // Members must be part of a class that has the `@TWWidgetDefinition` decorator
154 | // and must not have the `@description` decorator themselves
155 | if (
156 | node.parent.kind == ts.SyntaxKind.ClassDeclaration &&
157 | this.hasDecoratorNamed(WIDGET_SERVICE_DECORATOR, node)
158 | ) {
159 | if (!this.hasDecoratorNamed(DESCRIPTION_DECORATOR, node)) {
160 | return this.addDescriptionDecoratorToNode(node);
161 | }
162 | }
163 | }
164 |
165 | return ts.visitEachChild(node, (node) => this.visit(node), this.context);
166 | }
167 |
168 | /**
169 | * Adds the description decorator to the given node.
170 | * @param {ts.Node} node The node to add the description decorator to.
171 | * @param {ts.Node} originalNode If the target is node is transformed, and JSDoc tags cannot be obtained from it,
172 | * this should be set to the original untransformed node from which the documentation
173 | * can be obtained.
174 | * @returns {ts.Node} A transformed node containing the added description decorator.
175 | */
176 | addDescriptionDecoratorToNode(node, originalNode) {
177 | // The description is the JSDoc associated to the node, if there is one
178 | const documentation = ts.getJSDocCommentsAndTags(originalNode || node);
179 | if (!documentation.length) return node;
180 |
181 | let description = '';
182 |
183 | // Get the first documentation node and use it as the description
184 | if (documentation.length) {
185 | for (const documentationNode of documentation) {
186 | if (documentationNode.kind == ts.SyntaxKind.JSDocComment) {
187 | const comment = documentationNode.comment || '';
188 | if (typeof comment != 'string') {
189 | description = comment.reduce((acc, val) => acc + val.text, '');
190 | } else {
191 | description = comment;
192 | }
193 | break;
194 | }
195 | }
196 | }
197 |
198 | // Return if the description is empty
199 | if (!description) return node;
200 |
201 | // The description decorator is a decorator factory, so a call expression has to be created for it
202 | const descriptionCall = this.context.factory.createCallExpression(
203 | this.context.factory.createIdentifier(DESCRIPTION_DECORATOR),
204 | undefined,
205 | [this.context.factory.createStringLiteral(description, false)],
206 | );
207 |
208 | const decorator = this.context.factory.createDecorator(descriptionCall);
209 |
210 | switch (node.kind) {
211 | case ts.SyntaxKind.ClassDeclaration:
212 | /** @type {ts.ClassDeclaration} */ const classNode = node;
213 | return this.context.factory.updateClassDeclaration(
214 | classNode,
215 | [decorator].concat(classNode.decorators || []),
216 | classNode.modifiers,
217 | classNode.name,
218 | classNode.typeParameters,
219 | classNode.heritageClauses,
220 | classNode.members,
221 | );
222 | case ts.SyntaxKind.PropertyDeclaration:
223 | /** @type {ts.PropertyDeclaration} */ const propNode = node;
224 | return this.context.factory.updatePropertyDeclaration(
225 | propNode,
226 | [decorator].concat(propNode.decorators || []),
227 | propNode.modifiers,
228 | propNode.name,
229 | propNode.questionToken || propNode.exclamationToken,
230 | propNode.type,
231 | propNode.initializer,
232 | );
233 | case ts.SyntaxKind.MethodDeclaration:
234 | /** @type {ts.MethodDeclaration} */ const methodNode = node;
235 | return this.context.factory.updateMethodDeclaration(
236 | methodNode,
237 | [decorator].concat(methodNode.decorators || []),
238 | methodNode.modifiers,
239 | methodNode.asteriskToken,
240 | methodNode.name,
241 | methodNode.questionToken,
242 | methodNode.typeParameters,
243 | methodNode.parameters,
244 | methodNode.type,
245 | methodNode.body,
246 | );
247 | default:
248 | return node;
249 | }
250 | }
251 | }
252 |
253 | /**
254 | * Returns a description transformer function.
255 | * @return A transformer function.
256 | */
257 | function DescriptionTransformerFactory() {
258 | // Note that this function is currently useless, but can be used in the future to specify construction arguments
259 | return function DescriptionTransformerFunction(
260 | /** @type {ts.TransformationContext} */ context,
261 | ) {
262 | const transformer = new DescriptionTransformer(context);
263 |
264 | return (/** @type {ts.Node} */ node) =>
265 | ts.visitNode(node, (node) => transformer.visit(node));
266 | };
267 | }
268 |
269 | exports.DescriptionTransformer = DescriptionTransformer;
270 | exports.DescriptionTransformerFactory = DescriptionTransformerFactory;
271 |
--------------------------------------------------------------------------------
/webpack/TWClient.ts:
--------------------------------------------------------------------------------
1 | // This file is based on https://github.com/BogdanMihaiciuc/ThingCLI/blob/master/src/Utilities/TWClient.ts
2 | // No changes should be done here, rather they should be done upstream
3 | import * as fs from 'fs';
4 | import * as Path from 'path';
5 |
6 | /**
7 | * The options that may be passed to a thingworx request.
8 | */
9 | interface TWClientRequestOptions {
10 | /**
11 | * The endpoint to invoke
12 | */
13 | url: string;
14 |
15 | /**
16 | * An optional set of HTTP headers to include in addition to the
17 | * default thingworx headers.
18 | */
19 | headers?: Record;
20 |
21 | /**
22 | * An optional text or JSON body to send.
23 | */
24 | body?: string | Record;
25 |
26 | /**
27 | * An optional multipart body to send.
28 | */
29 | formData?: FormData;
30 | }
31 |
32 | /**
33 | * The interface for an object that contains the response returned from
34 | * a TWClient request.
35 | */
36 | interface TWClientResponse {
37 | /**
38 | * The response's body.
39 | */
40 | body: string;
41 |
42 | /**
43 | * The response headers.
44 | */
45 | headers: Headers;
46 |
47 | /**
48 | * The status code.
49 | */
50 | statusCode?: number;
51 |
52 | /**
53 | * The status message.
54 | */
55 | statusMessage?: string;
56 | }
57 |
58 | /**
59 | * A class that is responsible for performing requests to a thingworx server.
60 | */
61 | export class TWClient {
62 | /**
63 | * The cached package.json contents.
64 | */
65 | private static _cachedPackageJSON?: any;
66 |
67 | /**
68 | * The contents of the project's package.json file.
69 | */
70 | private static get _packageJSON() {
71 | if (this._cachedPackageJSON) return this._cachedPackageJSON;
72 | this._cachedPackageJSON = require(`${process.cwd()}/package.json`) as TWPackageJSON;
73 | return this._cachedPackageJSON;
74 | }
75 |
76 | /**
77 | * The cached connection details.
78 | */
79 | private static _cachedConnectionDetails?: TWPackageJSONConnectionDetails;
80 |
81 | /**
82 | * The cached header to use for Authentication.
83 | * Automatically set when the _cachedConnectionDetails are accessed
84 | */
85 | private static _authenticationHeaders?: Record;
86 |
87 | /**
88 | * The connection details to be used.
89 | */
90 | private static get _connectionDetails(): TWPackageJSONConnectionDetails {
91 | // Return the cached connection details if they exist.
92 | if (this._cachedConnectionDetails) {
93 | return this._cachedConnectionDetails;
94 | }
95 |
96 | // Otherwise try to get them from the environment variables, falling back to loading
97 | // them from package.json if they are not defined in the environment.
98 | if (!process.env.THINGWORX_SERVER) {
99 | console.error(
100 | 'The thingworx server is not defined in your environment, defaulting to loading from package.json',
101 | );
102 | this._cachedConnectionDetails = {
103 | thingworxServer: this._packageJSON.thingworxServer,
104 | thingworxUser: this._packageJSON.thingworxUser,
105 | thingworxPassword: this._packageJSON.thingworxPassword,
106 | thingworxAppKey: this._packageJSON.thingworxAppKey,
107 | };
108 | } else {
109 | this._cachedConnectionDetails = {
110 | thingworxServer: process.env.THINGWORX_SERVER,
111 | thingworxUser: process.env.THINGWORX_USER,
112 | thingworxPassword: process.env.THINGWORX_PASSWORD,
113 | thingworxAppKey: process.env.THINGWORX_APPKEY,
114 | };
115 | }
116 |
117 | // Try to authorize using an app key if provided, which is the preferred method
118 | if (this._connectionDetails.thingworxAppKey) {
119 | this._authenticationHeaders = { appKey: this._connectionDetails.thingworxAppKey };
120 | }
121 | // Otherwise use the username and password combo
122 | else if (
123 | this._connectionDetails.thingworxUser &&
124 | this._connectionDetails.thingworxPassword
125 | ) {
126 | const basicAuth = Buffer.from(
127 | this._connectionDetails.thingworxUser +
128 | ':' +
129 | this._connectionDetails.thingworxPassword,
130 | ).toString('base64');
131 | this._authenticationHeaders = { Authorization: 'Basic ' + basicAuth };
132 | } else {
133 | throw new Error(
134 | 'Unable to authorize a request to thingworx because an app key or username/password combo was not provided.',
135 | );
136 | }
137 |
138 | return this._cachedConnectionDetails;
139 | }
140 |
141 | /**
142 | * Returns the thingworx server.
143 | */
144 | static get server(): string | undefined {
145 | return this._connectionDetails.thingworxServer;
146 | }
147 |
148 | /**
149 | * Performs a request, returning a promise that resolves with its response.
150 | * @param options The requests's options.
151 | * @returns A promise that resolves with the response when
152 | * the request finishes.
153 | */
154 | private static async _performRequest(
155 | options: TWClientRequestOptions,
156 | method: 'get' | 'post' = 'post',
157 | ): Promise {
158 | const { thingworxServer: host } = this._connectionDetails;
159 |
160 | // Automatically prepend the base thingworx url
161 | options.url = `${host}/Thingworx/${options.url}`;
162 |
163 | // Automatically add the thingworx specific headers to options
164 | const headers = Object.assign(
165 | {},
166 | options.headers || {},
167 | {
168 | 'X-XSRF-TOKEN': 'TWX-XSRF-TOKEN-VALUE',
169 | 'X-THINGWORX-SESSION': 'true',
170 | Accept: 'application/json',
171 | },
172 | this._authenticationHeaders,
173 | );
174 |
175 | const fetchOptions: RequestInit = { method, headers };
176 |
177 | if (options.body) {
178 | // If the body is specified as an object, stringify it
179 | if (typeof options.body == 'object') {
180 | fetchOptions.body = JSON.stringify(options.body);
181 | } else {
182 | fetchOptions.body = options.body;
183 | }
184 | } else if (options.formData) {
185 | fetchOptions.body = options.formData;
186 | }
187 |
188 | const response = await fetch(options.url, fetchOptions);
189 |
190 | return {
191 | body: await response.text(),
192 | headers: response.headers,
193 | statusCode: response.status,
194 | statusMessage: response.statusText,
195 | };
196 | }
197 |
198 | /**
199 | * Deletes the specified extension from the thingworx server.
200 | * @param name The name of the extension to remove.
201 | * @returns A promise that resolves with the server response when the
202 | * operation finishes.
203 | */
204 | static async removeExtension(name: string): Promise {
205 | return await this._performRequest({
206 | url: `Subsystems/PlatformSubsystem/Services/DeleteExtensionPackage`,
207 | headers: {
208 | 'Content-Type': 'application/json',
209 | },
210 | body: { packageName: name },
211 | });
212 | }
213 |
214 | /**
215 | * Imports the specified extension package to the thingworx server.
216 | * @param data A form data object containing the extension to import.
217 | * @returns A promise that resolves with the server response when
218 | * the operation finishes.
219 | */
220 | static async importExtension(formData: FormData): Promise {
221 | return await this._performRequest({
222 | url: `ExtensionPackageUploader?purpose=import`,
223 | formData: formData,
224 | });
225 | }
226 |
227 | /**
228 | * Sends a POST request to the specified endpoint, with an empty body.
229 | * @param endpoint The endpoint.
230 | * @returns A promise that resolves with the server response when
231 | * the operation finishes.
232 | */
233 | static async invokeEndpoint(endpoint: string): Promise {
234 | return await this._performRequest({
235 | url: endpoint,
236 | headers: {
237 | 'Content-Type': 'application/json',
238 | },
239 | });
240 | }
241 |
242 | /**
243 | * Retrieves the metadata of the specified entity.
244 | * @param name The name of the entity.
245 | * @param kind The kind of entity.
246 | * @returns A promise that resolves with the server response when
247 | * the operation finishes.
248 | */
249 | static async getEntity(name: string, kind: string): Promise {
250 | const url = `${kind}/${name}${kind == 'Resources' ? '/Metadata' : ''}`;
251 | return await this._performRequest({ url }, 'get');
252 | }
253 |
254 | /**
255 | * Retrieves a list containing the entities that the specified entity depends on.
256 | * @param name The name of the entity.
257 | * @param kind The kind of entity.
258 | * @returns A promise that resolves with the server response when
259 | * the operation finishes.
260 | */
261 | static async getEntityDependencies(name: string, kind: string): Promise {
262 | const url = `${kind}/${name}/Services/GetOutgoingDependencies`;
263 | return await this._performRequest({
264 | url,
265 | headers: {
266 | 'Content-Type': 'application/json',
267 | },
268 | });
269 | }
270 |
271 | /**
272 | * Retrieves a list containing the entities that are part of the specified project.
273 | * @param name The name of the project.
274 | * @returns A promise that resolves with the server response when
275 | * the operation finishes.
276 | */
277 | static async getProjectEntities(name: string): Promise {
278 | const url = `Resources/SearchFunctions/Services/SpotlightSearch`;
279 | return await this._performRequest({
280 | url,
281 | headers: {
282 | 'Content-Type': 'application/json',
283 | },
284 | body: JSON.stringify({
285 | searchExpression: '**',
286 | withPermissions: false,
287 | sortBy: 'name',
288 | isAscending: true,
289 | searchDescriptions: true,
290 | aspects: {
291 | isSystemObject: false,
292 | },
293 | projectName: name,
294 | searchText: '',
295 | }),
296 | });
297 | }
298 |
299 | /**
300 | * Retrieves the typings file for the specified extension package.
301 | * @param name The name of the extension package.
302 | * @returns A promise that resolves with the server response when
303 | * the operation finishes.
304 | */
305 | static async getExtensionTypes(name: string): Promise {
306 | const url = `Common/extensions/${name}/ui/@types/index.d.ts`;
307 | return await this._performRequest({ url }, 'get');
308 | }
309 |
310 | /**
311 | * Retrieves the package details of the specified extension package.
312 | * @param name The name of the extension package.
313 | * @returns A promise that resolves with the server response when
314 | * the operation finishes.
315 | */
316 | static async getExtensionPackageDetails(name: string): Promise {
317 | return await this._performRequest({
318 | url: 'Subsystems/PlatformSubsystem/Services/GetExtensionPackageDetails',
319 | headers: {
320 | 'Content-Type': 'application/json',
321 | },
322 | body: JSON.stringify({ packageName: name }),
323 | });
324 | }
325 |
326 | /**
327 | * Executes the source control import of a path on the file repository into ThingWorx.
328 | * @param fileRepository Name of the ThingWorx FileRepository thing from where the import happens
329 | * @param path Path in the `fileRepository` where the entities are.
330 | * @param projectName Defaults to `'project'`. The name of the project being imported.
331 | * This is only used for error reporting if the import fails.
332 | * @returns A promise that resolves with the server response when
333 | * the operation finishes.
334 | */
335 | static async sourceControlImport(
336 | fileRepository: string,
337 | path: string,
338 | projectName?: string,
339 | ): Promise {
340 | const url = `Resources/SourceControlFunctions/Services/ImportSourceControlledEntities`;
341 |
342 | try {
343 | const response = await this._performRequest({
344 | url,
345 | headers: {
346 | 'Content-Type': 'application/json',
347 | },
348 | body: {
349 | repositoryName: fileRepository,
350 | path: path,
351 | includeDependents: false,
352 | overwritePropertyValues: true,
353 | useDefaultDataProvider: false,
354 | withSubsystems: false,
355 | },
356 | });
357 | if (response.statusCode != 200) {
358 | throw new Error(
359 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`,
360 | );
361 | }
362 | return response;
363 | } catch (err) {
364 | throw new Error(
365 | `Error executing source control import for project '${
366 | projectName || 'project'
367 | }' because: ${err}`,
368 | );
369 | }
370 | }
371 |
372 | /**
373 | * Uploads a local file into a ThingWorx file repository.
374 | * @param filePath Local path to the folder the file is in.
375 | * @param fileName Name of the file to be uploaded.
376 | * @param fileRepository Name of the TWX file repository the file should be uploaded to.
377 | * @param targetPath Remote path in the TWX file repository where the file should be stored.
378 | * @returns A promise that resolves with the server response when
379 | * the operation finishes.
380 | */
381 | static async uploadFile(
382 | filePath: string,
383 | fileName: string,
384 | fileRepository: string,
385 | targetPath: string,
386 | ): Promise {
387 | try {
388 | // load the file from the build folder
389 | let formData = new FormData();
390 | formData.append('upload-repository', fileRepository);
391 | formData.append('upload-path', targetPath);
392 | formData.append(
393 | 'upload-files',
394 | new Blob([fs.readFileSync(Path.join(filePath, fileName))]),
395 | fileName,
396 | );
397 | formData.append('upload-submit', 'Upload');
398 |
399 | // POST request to the Thingworx FileRepositoryUploader endpoint
400 | const response = await this._performRequest({
401 | url: 'FileRepositoryUploader',
402 | formData,
403 | });
404 |
405 | if (response.statusCode != 200) {
406 | throw new Error(
407 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`,
408 | );
409 | }
410 | return response;
411 | } catch (err) {
412 | throw new Error(`Error uploading file '${filePath}' into repository because: ${err}`);
413 | }
414 | }
415 |
416 | /**
417 | * Performs a unzip operation on a remote file in a ThingWorx file repository
418 | * @param fileRepository Name of the TWX FileRepository thing
419 | * @param filePath Remote path to where the zip file is
420 | * @param targetFolder Remote path to where the file should be extracted
421 | * @returns A promise that resolves with the server response when
422 | * the operation finishes.
423 | */
424 | static async unzipAndExtractRemote(
425 | fileRepository: string,
426 | filePath: string,
427 | targetFolder: string,
428 | ): Promise {
429 | const url = `Things/${fileRepository}/Services/ExtractZipArchive`;
430 |
431 | try {
432 | const response = await this._performRequest({
433 | url,
434 | headers: {
435 | 'Content-Type': 'application/json',
436 | },
437 | body: {
438 | path: targetFolder,
439 | zipFileName: filePath,
440 | },
441 | });
442 | if (response.statusCode != 200) {
443 | throw new Error(
444 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`,
445 | );
446 | }
447 | return response;
448 | } catch (err) {
449 | throw new Error(`Error executing remote file unzip because: ${err}`);
450 | }
451 | }
452 |
453 | /**
454 | * Deletes the specified remote folder in a ThingWorx file repository.
455 | * @param fileRepository Name of the FileRepository from which the folder should be deleted.
456 | * @param targetFolder Remote path to the folder to be deleted.
457 | * @returns A promise that resolves with the server response when
458 | * the operation finishes.
459 | */
460 | static async deleteRemoteDirectory(
461 | fileRepository: string,
462 | targetFolder: string,
463 | ): Promise {
464 | const url = `Things/${fileRepository}/Services/DeleteFolder`;
465 | try {
466 | const response = await this._performRequest({
467 | url,
468 | headers: {
469 | 'Content-Type': 'application/json',
470 | },
471 | body: {
472 | path: targetFolder,
473 | },
474 | });
475 | if (response.statusCode != 200) {
476 | throw new Error(
477 | `Got status code ${response.statusCode} (${response.statusMessage}). Body: ${response.body}`,
478 | );
479 | }
480 | return response;
481 | } catch (err) {
482 | throw new Error(`Error executing remote folder delete because: ${err}`);
483 | }
484 | }
485 |
486 | /**
487 | * Execute a source control export of the specified project.
488 | * @param project The name of the Thingworx project to export.
489 | * @param fileRepository Name of the FileRepository where the export will be saved.
490 | * @param path Remote path where the files should be exported to.
491 | * @param name Name of the folder where the files should be stored.
492 | * @returns A promise that resolves with the URL where the zip containing the exports is found
493 | * when the operation completes.
494 | */
495 | static async sourceControlExport(
496 | project: string,
497 | fileRepository: string,
498 | path: string,
499 | name: string,
500 | ): Promise {
501 | const { thingworxServer: host } = this._connectionDetails;
502 |
503 | try {
504 | // Do a ExportToSourceControl to export the project
505 | const exportResponse = await this._performRequest({
506 | url: 'Resources/SourceControlFunctions/Services/ExportSourceControlledEntities',
507 | headers: {
508 | 'Content-Type': 'application/json',
509 | },
510 | body: {
511 | projectName: project,
512 | repositoryName: fileRepository,
513 | path: path,
514 | name: name,
515 | exportMatchingModelTags: true,
516 | includeDependents: false,
517 | },
518 | });
519 |
520 | if (exportResponse.statusCode != 200) {
521 | throw new Error(
522 | `Got status code ${exportResponse.statusCode} (${exportResponse.statusMessage}). Body: ${exportResponse.body}`,
523 | );
524 | }
525 |
526 | // Create a zip from the folder that was exported
527 | await this._performRequest({
528 | url: `Things/${fileRepository}/Services/CreateZipArchive`,
529 | headers: {
530 | 'Content-Type': 'application/json',
531 | },
532 | body: {
533 | newFileName: project + '.zip',
534 | path: path,
535 | files: path + '/' + project + '/',
536 | },
537 | });
538 |
539 | return `${host}/Thingworx/FileRepositories/${fileRepository}/${path}/${project}.zip`;
540 | } catch (err) {
541 | throw new Error(
542 | `Error executing source control export for project '${project}' because: ${err}`,
543 | );
544 | }
545 | }
546 |
547 | /**
548 | * Downloads a remote file into the specified path.
549 | * @param fileUrl Path to the file to download.
550 | * @param targetPath Local path to where the file should be saved.
551 | * @returns A promise that resolves when the file has been written to the specified path.
552 | */
553 | static async downloadFile(fileUrl: string, targetPath: string): Promise {
554 | const response = await fetch(fileUrl, { headers: this._authenticationHeaders });
555 | fs.writeFileSync(targetPath, Buffer.from(await (await response.blob()).arrayBuffer()));
556 | }
557 | }
558 |
559 | /**
560 | * A subset of the package.json file for a thingworx vscode project that contains
561 | * the thingworx connection details.
562 | */
563 | interface TWPackageJSONConnectionDetails {
564 | /**
565 | * The URL to the thingworx server.
566 | */
567 | thingworxServer?: string;
568 |
569 | /**
570 | * The username to use when connecting to the thingworx server.
571 | */
572 | thingworxUser?: string;
573 |
574 | /**
575 | * The password to use when connecting to the thingworx server.
576 | */
577 | thingworxPassword?: string;
578 |
579 | /**
580 | * When specified, has priority over `thingworxUser` and `thingworxPassword`.
581 | * The app key to use when connecting to the thingworx server.
582 | */
583 | thingworxAppKey?: string;
584 | }
585 |
586 | /**
587 | * The interface for a package.json file with the thingworx vscode project specific
588 | * entries.
589 | */
590 | interface TWPackageJSON extends TWPackageJSONConnectionDetails {}
591 |
--------------------------------------------------------------------------------