Giter VIP home page Giter VIP logo

zerodep-web-push-java's Introduction

zerodep-web-push-java

A Java Web Push server-side library that can easily be integrated with various third-party libraries and frameworks.

This library

Versions

zerodep-web-push-java java version requirements
v2.x.x java 11 or higher
v1.x.x java 8 or higher

It is recommended that you use v2.x.x (the latest version of v2) if you can use java 11 or higher. Some features are only available in version 2.

The documentation specific to v1 is here.

Installation

<dependency>
  <groupId>com.zerodeplibs</groupId>
  <artifactId>zerodep-web-push-java</artifactId>
  <version>2.1.2</version>
</dependency>

How to use

Sending push notifications requires slightly complex steps. So it is recommended that you check one of the example projects(Please see Examples).

The following is a typical flow to send push notifications with this library.

  1. Generate a key pair for VAPID with an arbitrary way(e.g. openssl commands).

    Example:

    openssl ecparam -genkey -name prime256v1 -noout -out sourceKey.pem
    openssl pkcs8 -in sourceKey.pem -topk8 -nocrypt -out vapidPrivateKey.pem
    openssl ec -in sourceKey.pem -pubout -conv_form uncompressed -out vapidPublicKey.pem
  2. Instantiate VAPIDKeyPair with the key pair generated in '1.'.

    Example:

    VAPIDKeyPair vapidKeyPair = VAPIDKeyPairs.of(
        PrivateKeySources.ofPEMFile(new File(pathToYourPrivateKeyFile).toPath()),
        PublicKeySources.ofPEMFile(new File(pathToYourPublicKeyFile).toPath()
    );
  3. Send the public key for VAPID to the browser.

    Typically, this is achieved by exposing an endpoint to get the public key like GET /getPublicKey. Javascript on the browser fetches the public key through this endpoint.

    Example:

    @GetMapping("/getPublicKey")
    public byte[] getPublicKey() {
        return vapidKeyPair.extractPublicKeyInUncompressedForm();
    }

    (javascript on browser)

    const serverPublicKey = await fetch('/getPublicKey')
                                    .then(response => response.arrayBuffer());
    
    const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: serverPublicKey
    });
  4. Obtain a push subscription from the browser.

    Typically, this is achieved by exposing an endpoint for the browser to post the push subscription like POST /subscribe.

    Example:

    @PostMapping("/subscribe")
    public void subscribe(@RequestBody PushSubscription subscription) {
       this.saveSubscriptionToStorage(subscription);
    }

    (javascript on browser)

    await fetch('/subscribe', {
        method: 'POST',
        body: JSON.stringify(subscription),
        headers: {
            'content-type': 'application/json'
        }
    }).then(res => {
       .....
    });
  5. Send a push notification to the push service by using RequestPreparer (e.g. StandardHttpClientRequestPreparer) with the VAPIDKeyPair and the push subscription.

    HttpRequest request = StandardHttpClientRequestPreparer.getBuilder()
        .pushSubscription(subscription)
        .vapidJWTExpiresAfter(15, TimeUnit.MINUTES)
        .vapidJWTSubject("mailto:[email protected]")
        .pushMessage(message)
        .ttl(1, TimeUnit.HOURS)
        .urgencyLow()
        .topic("MyTopic")
        .build(vapidKeyPair)
        .toRequest();
    
    HttpResponse<String> httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());

Examples

Spring Boot (MVC)

Source code and usage: zerodep-web-push-java-example

Controller for VAPID and Message Encryption
    
@Component
public class MyComponents {

    /**
     * In this example, we read a key pair for VAPID
     * from a PEM formatted file on the file system.
     * <p>
     * You can extract key pairs from various sources:
     * '.der' file(binary content), an octet sequence stored in a database and so on.
     * For more information, please see the javadoc of PrivateKeySources and PublicKeySources.
     */
    @Bean
    public VAPIDKeyPair vaidKeyPair(
        @Value("${private.key.file.path}") String privateKeyFilePath,
        @Value("${public.key.file.path}") String publicKeyFilePath) throws IOException {

        return VAPIDKeyPairs.of(
            PrivateKeySources.ofPEMFile(new File(privateKeyFilePath).toPath()),
            PublicKeySources.ofPEMFile(new File(publicKeyFilePath).toPath())
        );
    }

}

    
@SpringBootApplication
@RestController
public class BasicExample {

    /**
     * @see MyComponents
     */
    @Autowired
    private VAPIDKeyPair vapidKeyPair;

    /**
     * # Step 1.
     * Sends the public key to user agents.
     * <p>
     * The user agents create a push subscription with this public key.
     */
    @GetMapping("/getPublicKey")
    public byte[] getPublicKey() {
        return vapidKeyPair.extractPublicKeyInUncompressedForm();
    }

    /**
     * # Step 2.
     * Obtains push subscriptions from user agents.
     * <p>
     * The application server(this application) requests the delivery of push messages with these subscriptions.
     */
    @PostMapping("/subscribe")
    public void subscribe(@RequestBody PushSubscription subscription) {
        this.saveSubscriptionToStorage(subscription);
    }

    /**
     * # Step 3.
     * Requests the delivery of push messages.
     * <p>
     * In this example, for simplicity and testability, we use an HTTP endpoint for this purpose.
     * However, in real applications, this feature doesn't have to be provided as an HTTP endpoint.
     */
    @PostMapping("/sendMessage")
    public ResponseEntity<String> sendMessage(@RequestBody MyMessage myMessage)
        throws IOException, InterruptedException {

        String message = myMessage.getMessage();

        HttpClient httpClient = HttpClient.newBuilder().build();
        for (PushSubscription subscription : getSubscriptionsFromStorage()) {

            HttpRequest request = StandardHttpClientRequestPreparer.getBuilder()
                .pushSubscription(subscription)
                .vapidJWTExpiresAfter(15, TimeUnit.MINUTES)
                .vapidJWTSubject("mailto:[email protected]")
                .pushMessage(message)
                .ttl(1, TimeUnit.HOURS)
                .urgencyLow()
                .topic("MyTopic")
                .build(vapidKeyPair)
                .toRequest();

            // In this example, we send push messages in simple text format.
            // You can also send them in JSON format as follows:
            //
            // ObjectMapper objectMapper = (Create a new one or get from the DI container.)
            // ....
            // .pushMessage(objectMapper.writeValueAsBytes(objectForJson))
            // ....

            HttpResponse<String> httpResponse =
                httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            logger.info(String.format("[Http Client] status code: %d", httpResponse.statusCode()));
            // 201 Created : Success!
            // 410 Gone : The subscription is no longer valid.
            // etc...
            // for more information, see the useful link below:
            // [Response from push service - The Web Push Protocol ](https://developers.google.com/web/fundamentals/push-notifications/web-push-protocol)
        }

        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE)
            .body("The message has been processed.");
    }
    
    ... Omitted for simplicity.
    
}
    

Spring Boot (WebFlux)

Source code and usage: zerodep-web-push-java-example-webflux

Vert.x

Source code and usage: zerodep-web-push-java-example-vertx

Standalone application for VAPID and Message Encryption
public class Example {

    /**
     * In this example, we read a key pair for VAPID
     * from a PEM formatted file on the file system.
     * <p>
     * You can extract key pairs from various sources:
     * '.der' file(binary content), an octet sequence stored in a database and so on.
     * For more information, please see the javadoc of PrivateKeySources and PublicKeySources.
     */
    private static VAPIDKeyPair createVAPIDKeyPair(Vertx vertx) throws IOException {
        return VAPIDKeyPairs.of(
            PrivateKeySources.ofPEMFile(new File("./.keys/my-private_pkcs8.pem").toPath()),
            PublicKeySources.ofPEMFile(new File("./.keys/my-pub.pem").toPath()),
            new VertxVAPIDJWTGeneratorFactory(() -> vertx));
    }

    public static void main(String[] args) throws IOException {

        Vertx vertx = Vertx.vertx();
        WebClient client = WebClient.create(vertx);
        Router router = Router.router(vertx);
        router.route().handler(BodyHandler.create());

        VAPIDKeyPair vapidKeyPair = createVAPIDKeyPair(vertx);
        MockSubscriptionStorage mockStorage = new MockSubscriptionStorage();

        /*
         * # Step 1.
         * Sends the public key to user agents.
         *
         * The user agents create a push subscription with this public key.
         */
        router
            .get("/getPublicKey")
            .handler(ctx ->
                ctx.response()
                    .putHeader("Content-Type", "application/octet-stream")
                    .end(Buffer.buffer(vapidKeyPair.extractPublicKeyInUncompressedForm()))
            );

        /*
         * # Step 2.
         * Obtains push subscriptions from user agents.
         *
         * The application server(this application) requests the delivery of push messages with these subscriptions.
         */
        router
            .post("/subscribe")
            .handler(ctx -> {

                PushSubscription subscription =
                    ctx.getBodyAsJson().mapTo(PushSubscription.class);
                mockStorage.saveSubscriptionToStorage(subscription);

                ctx.response().end();
            });

        /*
         * # Step 3.
         * Requests the delivery of push messages.
         *
         * In this example, for simplicity and testability, we use an HTTP endpoint for this purpose.
         * However, in real applications, this feature doesn't have to be provided as an HTTP endpoint.
         */
        router
            .post("/sendMessage")
            .handler(ctx -> {

                String message = ctx.getBodyAsJson().getString("message");
                vertx.getOrCreateContext().put("messageToSend", new SampleMessageData(message));

                ExamplePushMessageDeliveryRequestProcessor processor =
                    new ExamplePushMessageDeliveryRequestProcessor(
                        vertx,
                        client,
                        vapidKeyPair,
                        mockStorage.getSubscriptionsFromStorage()
                    );
                processor.start();

                ctx.response()
                    .putHeader("Content-Type", "text/plain")
                    .end("Started sending notifications.");
            });

        router.route("/*").handler(StaticHandler.create());

        vertx.createHttpServer().requestHandler(router).listen(8080, res -> {
            System.out.println("Vert.x HTTP server started.");
        });
    }

    /**
     * Sends HTTP requests to push services to request the delivery of push messages.
     * <p>
     * This class utilizes:
     * <ul>
     * <li>{@link Vertx#executeBlocking(Handler, Handler)} for the JWT creation and the message encryption.</li>
     * <li>{@link WebClient} for sending HTTP request asynchronously.</li>
     * </ul>
     */
    static class ExamplePushMessageDeliveryRequestProcessor {

        private final Vertx vertx;
        private final WebClient client;
        private final VAPIDKeyPair vapidKeyPair;
        private final List<PushSubscription> targetSubscriptions;

        private final int requestIntervalMillis;
        private final int connectionTimeoutMillis;

        ExamplePushMessageDeliveryRequestProcessor(
            Vertx vertx,
            WebClient client,
            VAPIDKeyPair vapidKeyPair,
            Collection<PushSubscription> targetSubscriptions) {

            this.vertx = vertx;
            this.client = client;
            this.vapidKeyPair = vapidKeyPair;
            this.targetSubscriptions = targetSubscriptions.stream().collect(Collectors.toList());
            this.requestIntervalMillis = 100;
            this.connectionTimeoutMillis = 10_000;
        }

        void start() {
            startInternal(0);
        }

        private void startInternal(int currentIndex) {

            PushSubscription subscription = targetSubscriptions.get(currentIndex);
            SampleMessageData messageData = vertx.getOrCreateContext().get("messageToSend");

            vertx.executeBlocking(promise -> {

                // In some circumstances, the JWT creation and the message encryption
                // may be considered "blocking" operations.
                //
                // On the author's environment, the JWT creation takes about 0.7ms
                // and the message encryption takes about 1.7ms.
                //
                // reference: https://vertx.io/docs/vertx-core/java/#golden_rule

                VertxWebClientRequestPreparer requestPreparer =
                    VertxWebClientRequestPreparer.getBuilder()
                        .pushSubscription(subscription)
                        .vapidJWTExpiresAfter(15, TimeUnit.MINUTES)
                        .vapidJWTSubject("mailto:[email protected]")
                        .pushMessage(messageData.getMessage())
                        .ttl(1, TimeUnit.HOURS)
                        .urgencyNormal()
                        .topic("MyTopic")
                        .build(vapidKeyPair);

                promise.complete(requestPreparer);

            }, res -> {

                VertxWebClientRequestPreparer requestPreparer =
                    (VertxWebClientRequestPreparer) res.result();
                requestPreparer.sendBuffer(
                    client,
                    req -> req.timeout(connectionTimeoutMillis),
                    httpResponseAsyncResult -> {

                        HttpResponse<Buffer> result = httpResponseAsyncResult.result();
                        System.out.println(
                            String.format("status code: %d", result.statusCode()));
                        // 201 Created : Success!
                        // 410 Gone : The subscription is no longer valid.
                        // etc...
                        // for more information, see the useful link below:
                        // [Response from push service - The Web Push Protocol ](https://developers.google.com/web/fundamentals/push-notifications/web-push-protocol)

                    }
                );

            });

            if (currentIndex == targetSubscriptions.size() - 1) {
                return;
            }

            // In order to avoid wasting bandwidth,
            // we send HTTP requests at some intervals.
            vertx.setTimer(requestIntervalMillis, id -> startInternal(currentIndex + 1));
        }
    }
    
    ... Omitted for simplicity.
    
}

Motivation

'zerodep-web-push-java' assumes that suitable implementations(libraries) of the following functionalities vary depending on applications.

  • Generating and signing JSON Web Token(JWT) used for VAPID
  • Sending HTTP requests for the delivery of push messages
  • Cryptographic operations

For example, an application may need to send HTTP requests synchronously with Apache HTTPClient but another application may need to do this asynchronously with Vert.x.

In order to allow you to choose the way suitable for your application, this library doesn't force your application to have dependencies on specifics libraries. Instead, this library

  • Provides the functionality of JWT for VAPID with sub-modules
  • Also, provides the functionality of JWT for VAPID out of the box(without any third-party library)
  • Provides optional components helping applications use various third-party HTTP Client libraries
  • Also, provides a component helping applications use JDK's HTTP Client module.
  • Utilizes the Java Cryptography Architecture (JCA) for cryptographic operations

Each of the sub-modules utilizes a specific JWT library. Each of the optional components supports a specific HTTP Client library. you can choose suitable modules/components for your requirements. JCA enables this library to be independent of specific implementations(providers) for security functionality.

Various sub-modules and helper components

The following functionalities can be provided from outside this library.

JWT

JWT libraries are used to generate JSON Web Token (JWT) for VAPID.

Sub-modules for this functionality are available from zerodep-web-push-java-ext-jwt.

These sub-modules are optional.

HTTP Client

Application servers need to send HTTP requests to push services in order to request the delivery of push messages. Helper components for this functionality are available from the com.zerodeplibs.webpush.httpclient package. One of them utilizes JDK's HTTP Client module . The others utilize third-party HTTP Client libraries. Supported third-party libraries are listed below.

  • OkHttp

    Version 4.9.0 or higher. The latest version is recommended.

  • Apache HTTPClient

    Version 5.1 or higher. The latest version is recommended.

  • Eclipse Jetty Client Libraries

    • Jetty 9: 9.4.33.v20201020 or higher.
    • Jetty 10: 10.0.0 or higher.
    • Jetty 11: 11.0.0 or higher.

    The latest versions are recommended.

  • Vert.x Web Client

    • Vert.x 3: 3.9.2 or higher.
    • Vert.x 4: 4.0.0 or higher.

    The latest versions are recommended.

  • Others

    'zerodep-web-push-java' doesn't directly provide optional components for the libraries other than the above. However, 'zerodep-web-push-java' can be easily integrated with the other HTTP Client libraries and frameworks. For example, you can also utilize the following libraries.

    Please see zerodep-web-push-java-example-webflux for more information.

MISC

Null safety

The public methods and constructors of this library do not accept nulls and do not return nulls. They throw an Exception if a null reference is passed. Some methods return java.util.Optional.empty() if they need to indicate that the value does not exist.

The exceptions are:

  • com.zerodeplibs.webpush.PushSubscription.java. This is the server-side representation of push subscription.
  • The methods of Exception. For example, their getCause() can return null.
Working with Java Cryptography Architecture(JCA)

This library uses the Java Cryptography Architecture (JCA) API for cryptographic operations. The algorithms used by this library are listed below.

java.security.SecureRandom
java.security.KeyFactory.getInstance("EC") 
java.security.KeyPairGenerator.getInstance("EC") // curve: secp256r1
java.security.Signature.getInstance("SHA256withECDSA")
javax.crypto.KeyAgreement.getInstance("ECDH")
javax.crypto.Mac.getInstance("HmacSHA256") 
javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")

By default, the providers shipped with the JDK will be used(e.g. SunEC and SunJCE).

Of course, any provider that supports these algorithms is available( e.g. Bouncy Castle). This is because 'zerodep-web-push-java' has no dependencies on any specific provider.

License

MIT

Contribution

This project follows a git flow -style model.

Please open pull requests against the dev branch.

zerodep-web-push-java's People

Contributors

dependabot[bot] avatar ralscha avatar st-user avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar

Forkers

cgb-im blama

zerodep-web-push-java's Issues

Introduce a JWT generator for VAPID without any 3rd party library

In order for users to enjoy the Web Push functionality more easily, we are going to provide a default implementation for the JWT generator for VAPID from 'zerodep-web-push-java'(not from 'zerodep-web-push-java-ext-jwt'). The implementation we make doesn't require any third party library. This makes it possible for users to use 'zerodep-web-push-java' without adding a third party library for this functionality.

Explicitly set a message when wrapping an exception.

In this library, we wrap a number of checked exceptions without a message.

In order to clarify the situation, we should set a message explicitly.

list of exceptions

  • com.zerodeplibs.webpush.jwt.MalformedURLRuntimeException
  • com.zerodeplibs.webpush.jwt.VAPIDJWTCreationException(Introduced at #1)
  • com.zerodeplibs.webpush.key.KeyExtractionException
  • com.zerodeplibs.webpush.key.MalformedPEMException
  • com.zerodeplibs.webpush.MessageEncryptionException

Provide copy-constructor of PushSubscription.

PushSubscription(com.zerodeplibs.webpush.PushSubscription) is mutable. It provides setter methods for JSON deserializers such as Jackson.

We suppose that many users copy its instances in order to process them safely in some circumstances.

In order to make it easy to copy its instances, we are going to provide a copy-constructor.

Provide a clean and easy way to make requests for push message delivery

Currently, in order to make a request for push message delivery, users have to invoke MessageEncryption#encrypt with proper arguments, generate a JWT for VAPID by calling VAPIDKeyPair#generateAuthorizationHeaderValue and set HTTP header fields and the request body manually.

This process is slightly complex and might be a burden on users.

So we want to simplify this process by providing components which encapsulate it like:

// This component performs message encryption,
// creates a JWT for VAPID and makes an OkHttp's request object.
Request request = AnExampleComponentForOkHttp.getBuilder(vapidKeyPair)  
    .pushSubscription(subscription)  
    .vapidJWTExpiresAfter(10)  
    .vapidJWTSubject("mailto:[email protected]")  
    .pushMessage("A push message.")  
    .build()  
    .toRequest()

try (Response response = okHttpClient.newCall(request).execute()) {
  ....
}

In order to achieve this, we have to make components which have a dependency on a specific third-party HTTP client library.
But of course, we must not force users to have a dependency on a specific third-party library.
So we have to make dependencies on third-party libraries 'optional'.

Libraries to be supported

We are going to support the following libraries:

reference

VAPID Public/Private key

Could you please give an example of Public/Private keys?
I tried to generate VAPID keys both from any node.js service and openssl and I can't get this library work with any of those.

Make it easy to configure VAPIDJWTGenerator.

Background

Currently this library doesn't provide implementations of VAPIDJWTGenerator. This is because we want to make this library have no dependencies on specific JWT libraries.

As a result, the users of this library have to implement VAPIDJWTGenerator themself.

TODO

While keeping this library not forcing the users to have dependencies on specific JWT libraries, we want to provide some implementations of VAPIDJWTGenerator.

We think we can achieve this by doing:

  • Check whether a JWT library's class is present on the classpath.
  • If the class is present, instantiate the VAPIDJWTGenerator implementation that uses the JWT library.

(idea: WebMvcConfigurationSupport.java - Springframework)

We are going to support at least the following three libraries.

EDIT 2021/10/16:

We decided to make sub-modules that provide an implementation of VAPIDJWTGenerator.
These sub-modules provide their own implementation by using the ServiceLoader mechanism.

These sub-modules are developed on another repository(zerodep-web-push-java-ext).

Also, we decided to support the following five libraries.

Supports Jetty Client 12

Context

Remove unnecessary configuration in pom.xml.

The pom.xml has the following configuration for maven-deploy-plugin.

<configuration>
    <altDeploymentRepository>
        internal.repo::default::file://${project.build.directory}/../../zerodep-web-push-java-test-repo
    </altDeploymentRepository>
</configuration>

Currently this configuration isn't necessary so we should remove it.

Remove deprecated features

Remove the following two deprecated methods:

  • com.zerodeplibs.webpush.jwt.VAPIDJWTParam.Builder#expiresAt
  • com.zerodeplibs.webpush.jwt.VAPIDJWTParam.Builder#expiresAfterSeconds

Connection timed out from FCM after sending some Push messages

Yesterday I was testing sending some push messages to an FCM endpoint, but suddenly my backend was not responding to my requests.
Investigating a little bit looks like the FCM server is not responding anymore to my requests.

[...]
HttpRequest httpRequest = StandardHttpClientRequestPreparer.getBuilder()
  .pushSubscription(mapperService.getUserSubscriptionDto(userSubscription).getSubscription())
  .pushMessage(objectMapper.writerWithView(Views.NoRecipients.class).writeValueAsBytes(notificationDto))
  .topic(notificationDto.getSubject())
  .ttl(1, TimeUnit.HOURS)
  .urgency(notificationDto.getPriority())
  .vapidJWTExpiresAfter(15, TimeUnit.MINUTES)
  .vapidJWTSubject(notificationDto.getTenant())
  .build(vapidKeyPair)
  .toRequest();
Integer resultCode = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()).statusCode();
pushResponseList.add(
	PushResponseDto.builder()
	.subscriptionId(userSubscription.getId())
	.resultCode(resultCode)
	.build()
);
} catch (JsonProcessingException e) {
	throw new InternalServerErrorException("Could not create Push Message", e);
} catch (IOException e) {
	throw new InternalServerErrorException("Could not send Push Message", e);
} catch (InterruptedException e) {
	// TODO Auto-generated catch block
	e.printStackTrace();
}

The execution stops at httpClient.send(), and after a while it throws the IOException.
The cause of the exception is:

Caused by: java.net.ConnectException: Connection timed out: no further information
        at java.net.http/jdk.internal.net.http.HttpClientImpl.send(HttpClientImpl.java:561)
        at java.net.http/jdk.internal.net.http.HttpClientFacade.send(HttpClientFacade.java:119)
        ... 72 more
Caused by: java.net.ConnectException: Connection timed out: no further information
        at java.base/sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
        at java.base/sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:779)
        at java.net.http/jdk.internal.net.http.PlainHttpConnection$ConnectEvent.handle(PlainHttpConnection.java:128)
        at java.net.http/jdk.internal.net.http.HttpClientImpl$SelectorManager.handleEvent(HttpClientImpl.java:957)
        at java.net.http/jdk.internal.net.http.HttpClientImpl$SelectorManager.lambda$run$3(HttpClientImpl.java:912)
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
        at java.net.http/jdk.internal.net.http.HttpClientImpl$SelectorManager.run(HttpClientImpl.java:912)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.