neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
한 줄 명령으로 Shopify 앱을 만들고 Firebase에 배포해 설치 링크를 제공합니다
One command scaffolds a Shopify app, deploys it to Firebase, and hands you an install link. No framework, no server, and an AI agent can drive it.
개발 도구JavaScript · HTMLGitHub @mksd0398온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Scaffold, deploy and install a Shopify app on Firebase in one command. Cloud Functions, Firestore, Hosting, Polaris web components and App Bridge. No framework, no server. Ships an agent skill for Claude Code, Cursor, Codex and Copilot.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
One command scaffolds a Shopify app, deploys it to Firebase, and hands you an install link.
Cloud Functions, Firestore and Hosting. Polaris web components and App Bridge. No framework, no server.
Inert by design — nothing running while nobody is using it.
Hydrogen is the fuel. Oxygen is the oxidiser. Nitrogen is the 78% of the atmosphere that everything else just runs inside.
Website · Will it be free for my app? Try the cost calculator · Build it with an AI agentREADME 전문 보기 ↗One command scaffolds a Shopify app, deploys it to Firebase, and hands you an install link. Cloud Functions, Firestore and Hosting. Polaris web components and App Bridge. No framework, no server.
Inert by design — nothing running while nobody is using it.
Hydrogen is the fuel. Oxygen is the oxidiser. Nitrogen is the 78% of the atmosphere that everything else just runs inside.
npx @mksd0398/nitrogen my-app
Website · Will it be free for my app? Try the cost calculator · Build it with an AI agent
The Firebase alternative to shopify app init. Instead of Remix + Prisma + a server, you get:
npm test covers auth, webhooks, routing and wiringOne command does the whole thing: signs you in to Shopify, creates the app, creates and provisions the Firebase project, stores the client secret in Secret Manager, deploys, and hands you an install link. No copying Client IDs between dashboards, and no pasting your Client Secret — the CLI reads it from the Shopify CLI itself.
The CLI checks all of these before it does anything, and offers to install what's missing.
| Tool | Install | Required? |
|---|---|---|
| Node.js 20+ | nodejs.org | Yes — Cloud Functions run on Node 24 |
| Firebase CLI | npm i -g firebase-tools | Yes — offered automatically if missing |
| Shopify CLI | npm i -g @shopify/cli | Yes — offered automatically if missing |
| git | git-scm.com | Optional — only for the initial commit |
| gcloud | Cloud SDK | Optional — auto-enables the Firestore API |
You also need a Shopify Partner account and a Google account. The CLI signs you in to both; you don't need to open either dashboard first.
Cloud Functions and Secret Manager require the Firebase Blaze plan. Blaze includes the free tier described in What It Costs, but a billing account has to be attached. That is the one step the CLI cannot do for you.
npx @mksd0398/nitrogen my-app
The CLI walks the entire flow:
🛍️ + 🔥 Nitrogen
=== Environment Check ===
✔ Node.js v22.20.0
✔ npm 10.9.4
✔ git 2.47.1
✔ Firebase CLI 15.10.0
✔ Shopify CLI 4.6.0
✔ gcloud 565.0.0
✔ Environment ready
=== Sign In to Shopify ===
ℹ A browser window opens — approve the code shown below.
✔ Signed in to Shopify
=== Choose Your Template ===
? What would you like to create?
❯ Shopify + Firebase app (full-stack serverless)
Extension-only app (Shopify CLI)
=== Project Setup ===
? Language for Cloud Functions › TypeScript
? What API access does your app need? › read_products
? Who is this app for? › Many stores (multi-tenant)
=== Create Your Shopify App ===
(the Shopify CLI asks which organization, and lets you create a new app)
✔ Client ID: 12dfc4d0d6d0b40d85b0f64b80de28af
✔ Client Secret: shpss_09**************(38 chars)
=== Firebase Setup ===
? Select a Firebase project
❯ [create a new project]
? Where should your database live? › europe-west1
✔ Firestore: europe-west1
✔ Hosting: https://my-store-app.web.app
? Where should your app's functions run? › europe-west1 (next to your database)
✔ Functions region: europe-west1
✔ App config written — https://my-store-app.web.app
=== Going Live ===
✔ Client Secret stored in Secret Manager
✔ Deployed to Firebase
✔ Shopify app updated
✔ All done! Your Shopify + Firebase app is ready.
You never paste the Client Secret. The CLI reads it from shopify app env show
once the app exists — which is exactly why the Shopify app is created before Firebase.
Install the app from your Partner Dashboard (Apps → your app → Test on development store). Shopify installs it and opens it embedded in the admin; the app obtains its access token by token exchange on the first request.
The legacy OAuth link also works:
https://YOUR-PROJECT.web.app/auth?shop=YOUR-STORE.myshopify.com
cd my-store-app
# edit web/ or functions/src/
cd functions && npm test && cd ..
firebase deploy --force
Nitrogen ships an agent skill that teaches an AI coding agent how a Nitrogen app fits together. With it, an agent can:
without breaking the security and tenancy rules.
npx skills add mksd0398/nitrogen
That installs it for Claude Code, Cursor, Codex, GitHub Copilot, Gemini CLI and
the other agents the skills CLI
supports. In Claude Code you can add it as a plugin instead:
/plugin marketplace add mksd0398/nitrogen
/plugin install nitrogen@nitrogen
Every generated app also carries an AGENTS.md, plus a CLAUDE.md that imports
it. An agent opened inside the project follows the same rules with nothing installed.
An agent has no terminal to answer the wizard's questions in, so it passes the
answers as flags. The client secret goes in SHOPIFY_API_SECRET, which keeps it
out of the command line and out of the agent's transcript:
SHOPIFY_API_SECRET="$(cat secret.txt)" npx @mksd0398/nitrogen my-app \
--api-key=CLIENT_ID --project-id=FIREBASE_PROJECT_ID
Nothing runs while nobody is using your app. Every function scales to zero, so an app with no traffic costs nothing, and a small app fits inside the free tier. What It Costs has the arithmetic.
shopify app init (Remix) | Nitrogen | |
|---|---|---|
| Backend | One Remix server | Firebase v2 Cloud Functions, one per API resource |
| Database | Prisma + PostgreSQL | Cloud Firestore |
| Frontend | React + Polaris | Polaris web components + App Bridge (no build) |
| Hosting | Vercel / Fly.io / Heroku | Firebase Hosting |
| Auth | @shopify/shopify-app-react-router | Four verifiers in one file, verify.ts — you own it |
| Secrets | Host environment variables | Google Secret Manager |
| Build | Vite | tsc (no bundler), or nothing for JavaScript |
| Deploy | Varies | Done for you — the CLI deploys before it exits |
| Idle cost | A server that is always on | Zero — everything scales to zero |
| Scaling | Single server | Per-function (Cloud Run), capped by maxInstances |
| GDPR webhooks | Auto-handled | Included, shop/redact implemented |
| Theme extensions | Supported | Supported (same Shopify format) |
my-app/
├── shopify.app.toml # Shopify app config (API 2026-10)
├── firebase.json # Hosting rewrites, headers, functions, Firestore
├── firestore.rules # Deny-all: only Cloud Functions touch the data
│
├── functions/ # ── Backend ──
│ ├── src/
│ │ ├── index.ts # THE API SURFACE: one export per function
│ │ ├── http.ts # endpoint(): auth, rate limit, routing, errors
│ │ ├── verify.ts # Every Shopify signature check, fail-closed
│ │ ├── config.ts # App registry, secrets, region
│ │ ├── auth.ts # OAuth, token exchange, sessions
│ │ ├── shopify.ts # Admin GraphQL client
│ │ ├── api-keys.ts # Keys for callers outside Shopify
│ │ ├── webhooks.ts # Webhook handlers
│ │ ├── proxy.ts # Storefront App Proxy routes
│ │ ├── firebase.ts # Firebase Admin SDK init
│ │ └── api/ # One file per resource
│ │ ├── shop.ts # GET /api/shop
│ │ ├── products.ts # GET /api/products/search, /api/products/:id
│ │ ├── settings.ts # GET, POST /api/settings
│ │ ├── keys.ts # GET, POST /api/keys, DELETE /api/keys/:id
│ │ └── status.ts # GET /api/status (session OR API key)
│ ├── test/ # npm test — no emulator or network needed
│ ├── .env # Client id, URL, scopes, region (git-ignored)
│ └── .secret.local # Client secret, for the emulator only (git-ignored)
│
├── web/ # ── Frontend (6 pages) ──
│ ├── index.html # Dashboard
│ ├── products.html # Product search + detail
│ ├── settings.html # Form persisted to Firestore
│ ├── keys.html # Create and revoke API keys
│ ├── polaris.html # Every Polaris component, with examples
│ ├── apis.html # Every App Bridge API, with Run buttons
│ ├── js/app.js # apiFetch (session token + one 401 retry), toast
│ ├── js/pages/ # One module per page
│ └── css/app.css
│
└── extensions/ # ── Storefront ──
└── theme-block/
JavaScript projects have the same files with .js.
Shopify admin (iframe) Storefront Shopify Outside systems
web/ + App Bridge theme / App Proxy webhooks mobile app, script
│ │ │ │
│ session token │ signature │ HMAC │ API key
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────────────────┐
│ Firebase Hosting — HTTPS only, rewrites each path to one function │
└──────────────────────────────────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ shop │ │ products │ │ proxy │ │ webhooks │ │ status │
│ settings │ │ keys │ │ │ │ auth │ │ │
└───────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘
Each box is its own Cloud Run service: own scaling, memory, timeout, logs, deploy.
│ │
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Cloud Firestore │ │ Secret Manager │
│ shopSessions access tokens │ │ SHOPIFY_API_SECRET │
│ appSettings per tenant │ │ mounted into each function │
│ apiKeys sha256 only │ └──────────────────────────────┘
│ authNonces OAuth state │
└──────────────────────────────┘
| Function | Path | Caller | Verified by |
|---|---|---|---|
auth | /auth, /auth/callback | Browser, during a legacy install | OAuth HMAC + single-use nonce |
webhooks | /webhooks | Shopify | HMAC over the raw body |
proxy | /proxy/** | Storefront, through the App Proxy | Query signature |
shop | /api/shop | Embedded admin | Session token |
products | /api/products/** | Embedded admin | Session token |
settings | /api/settings | Embedded admin | Session token |
keys | /api/keys/** | Embedded admin | Session token |
status | /api/status | Embedded admin or an outside system | Session token or API key |
A single Express app behind /api/** is one Cloud Run service: every route shares
its instances, its memory ceiling, its timeout and its fate. One slow route — a CSV
import, a report — starves everything else, including the routes merchants are
waiting on.
One function per resource means each one scales, fails, times out and deploys on its own, and a heavy resource can be given more memory without paying for it everywhere.
What it costs you:
npm test checks they agree.firebase deploy --only functions.Routes that belong together stay together: products serves both search and
detail. Split further only when a route needs different resources or must not
share fate with its neighbours.
An API is three things that must agree. Say the resource is orders.
1. The routes — functions/src/api/orders.ts
import { endpoint, HttpError } from "../http";
import { graphql } from "../shopify";
export default endpoint({
"GET /api/orders": async (ctx) => {
const data = await graphql(ctx, `{ orders(first: 10) { nodes { id name } } }`);
return { orders: data.orders.nodes };
},
"GET /api/orders/:id": async (ctx) => {
if (!/^\d+$/.test(ctx.params.id)) throw new HttpError(400, "Invalid order id");
const data = await graphql(
ctx,
`query ($id: ID!) { order(id: $id) { id name } }`,
{ id: `gid://shopify/Order/${ctx.params.id}` },
);
if (!data.order) throw new HttpError(404, "Order not found");
return { order: data.order };
},
});
A route returns the JSON to send, or throws HttpError. Anything else
thrown becomes a 500 whose detail stays in the logs.
2. The export — functions/src/index.ts
import ordersApi from "./api/orders";
export const orders = onRequest(base, ordersApi);
This line is what creates the Cloud Function. index.ts is the whole API surface:
if it is not exported there, it does not exist.
3. The rewrites — firebase.json
{ "source": "/api/orders", "run": { "serviceId": "orders", "region": "europe-west1" } },
{ "source": "/api/orders/**", "run": { "serviceId": "orders", "region": "europe-west1" } }
Then:
cd functions && npm test # fails if the export and the rewrites disagree
firebase deploy --only functions:orders,hosting
ctx | |
|---|---|
ctx.shop | The shop, e.g. demo.myshopify.com. From the verified credential, never from the request. |
ctx.app | The Shopify app the caller belongs to (see Multi-tenancy). |
ctx.userId | Staff user id, for session callers. |
ctx.apiKey | The key used, for API-key callers. |
ctx.params | Path parameters, decoded. |
ctx.query, ctx.body | Query string and parsed JSON body. Validate them — they are caller input. |
ctx.req, ctx.res | The raw request and response, when you need them. |
endpoint(routes) // the embedded admin
endpoint(routes, { apiKey: "read" }) // ...and API keys with the "read" scope
endpoint(routes, { auth: "proxy" }) // the storefront, via the App Proxy
endpoint(routes, { rateLimit: { max: 30, windowSeconds: 60 } })
export const imports = onRequest(
{ ...base, memory: "1GiB", timeoutSeconds: 60 },
importsApi,
);
Each of these cost real time in production.
| Trap | What happens |
|---|---|
serviceId is the export name lowercased | orderSync deploys as ordersync. A camelCase serviceId makes Hosting refuse to finalize. |
| Region mismatch between function and rewrite | The deploy succeeds. Hosting serves 404. |
Dropping invoker: "public" | An update serves Google's own 403 page, which reads exactly like a CORS bug. |
| Hosting cuts a rewritten request at 60 seconds | Whatever timeoutSeconds says. Long work is chunked by the client, or queued. |
--only functions:a after changing a shared module | Every other function stays on the old code. |
Removing a function from index.ts | A non-interactive deploy refuses. Run firebase functions:delete <name> --region <region> --force first. |
| Work started after the response is sent | Gen 2 throttles CPU once the response is delivered. Await it, or queue it. |
| Entry point | Check | Fails closed when |
|---|---|---|
| Webhooks | HMAC-SHA256 over the raw body, timing-safe | The header or the body is missing, the HMAC is wrong, or the shop domain is malformed |
| Admin API | App Bridge session token: HS256, aud, exp, nbf, issuer | Any claim is wrong, or the token is more than 10 seconds past expiry |
| App Proxy | Signature over the sorted query string | The signature is missing or wrong |
| OAuth callback | HMAC, then a single-use nonce bound to the shop and the app, 10-minute life | The nonce is unknown, expired, replayed, or belongs to another shop or app |
| API keys | sha256 lookup, scope, active flag | The key is unknown, revoked, or carries another scope |
Also:
functions/.env. Everything in
functions/ is baked into the deployed container; .gitignore has no say in that.shopSessions.*.myshopify.com before they reach a
redirect, an outbound URL or a document id.Internal server error.Content-Security-Policy: frame-ancestors
limited to Shopify, X-Content-Type-Options: nosniff, and Cache-Control: no-cache for JS and CSS so a deploy never runs new HTML against old scripts.[!IMPORTANT] Apps scaffolded before 3.1.0 accept unsigned webhooks. The check was
if (req.rawBody && hmac && !verify(...)), so a request with no HMAC header skipped verification. An unsignedapp/uninstalledPOST naming any shop deleted that shop's access token. If you have such an app, replace the check infunctions/src/webhookswith:const ok = hmac && req.rawBody ? verifyWebhookHmac(req.rawBody, hmac) : false; if (!ok) { res.status(401).send("Unauthorized"); return; }
The wizard asks who the app is for:
| Answer | What you get |
|---|---|
| One store (single-tenant) | The app is locked to that store. Any other shop is refused at install, on the API, on webhooks and on the storefront proxy. |
| Many stores (multi-tenant) | Any shop that installs the app is served, each with its own isolated data. |
The answer is ALLOWED_SHOPS in functions/.env: empty for multi-tenant, one
domain for single-tenant, or several separated by commas. Change it and redeploy
the functions to switch.
Beyond that there are two axes, and they are different things.
Shops. One app, many stores. Every document is keyed by the shop, and the shop always comes from a verified credential. This is on from the first install.
Apps. Several Shopify apps — one per brand, say — on the same functions and
the same Firestore. functions/src/config.ts holds the registry:
export const APPS = [
{ key: "default", clientIdEnv: "SHOPIFY_API_KEY", appUrlEnv: "APP_URL",
secret: defineSecret("SHOPIFY_API_SECRET") },
{ key: "brand-b", clientIdEnv: "BRAND_B_API_KEY", appUrlEnv: "BRAND_B_APP_URL",
secret: defineSecret("SHOPIFY_API_SECRET_BRAND_B") },
];
| Entry point | The app is |
|---|---|
| Webhook | The one whose secret verifies the HMAC |
| App Proxy | The one whose secret verifies the signature |
| OAuth callback | The one whose secret verifies the HMAC, which must match the nonce |
| Session token | Chosen by aud, then verified with that app's secret and audience, so the choice is covered by the signature |
| API key | The app that created the key |
A storefront cannot ask for another brand's answer because it cannot sign as the
other app. A brand parameter would be forgeable; a signature is not.
A tenant is a shop under one app. Its document id is the shop domain for the
default app, and shop__appkey for every other app, so data written before a
second app existed needs no migration.
| Collection | Document id | Holds |
|---|---|---|
shopSessions | tenant | Access token, scopes, the app that stored it |
appSettings | tenant | The settings form |
apiKeys | sha256 of the key | Name, scope, tenant, last use |
authNonces | the nonce | Shop, app, expiry |
APPS, and its client id and URL to functions/.env.firebase functions:secrets:set SHOPIFY_API_SECRET_BRAND_B<meta name="shopify-api-key"> before any script runs, so the pages cannot
choose a key at run time: copy web/ with the other client id, and add a
second hosting entry to firebase.json with the same rewrites.shopify.app.toml in its own directory. The Shopify CLI pins one
config per directory.The CLI scaffolds one app. Steps 3 and 4 are manual.
Four layers, each stopping a different failure.
| Layer | Where | Limit | Stops |
|---|---|---|---|
| Instance cap | setGlobalOptions({ maxInstances: 10 }) in index.ts | 10 instances per function | A traffic spike or retry storm turning into an unbounded bill |
| Per-caller limit | endpoint() | 300 requests/minute per tenant; 60/minute per API key | A runaway loop, a leaked key |
| Shopify's limit | graphql() | Shopify's 429 or THROTTLED is passed on as 429 with Retry-After | Hammering a shop that is already throttled |
| Key usage writes | api-keys.ts | lastUsedAt written at most once a minute per key | A write per API call |
Override per function:
endpoint(routes, { rateLimit: { max: 30, windowSeconds: 60 } })
endpoint(routes, { rateLimit: false })
The per-caller limit is counted in memory, per instance, so its real ceiling is
maxInstances × max. That is enough to stop a loop or a leaked key. If you need a
hard global limit, count in a shared store.
Storefront (proxy) traffic has no per-caller limit: every shopper of a store
arrives as the same tenant, from Shopify's own addresses, so there is nothing fair
to count by. The instance cap covers it.
Webhooks have 5 seconds, Hosting cuts at 60, and gen 2 throttles CPU after the response. Work that outlives a request belongs on a queue, and the queue is where you throttle the load you put on someone else's API:
import { onTaskDispatched } from "firebase-functions/v2/tasks";
export const syncWorker = onTaskDispatched(
{
retryConfig: { maxAttempts: 5, minBackoffSeconds: 60 },
rateLimits: { maxConcurrentDispatches: 4, maxDispatchesPerSecond: 2 },
},
async (req) => { /* one unit of work */ },
);
Enqueue with getFunctions().taskQueue("syncWorker").enqueue(data). Not scaffolded
by default: it needs the Cloud Tasks API and only matters once you have such work.
Blaze is pay-as-you-go with a free tier. You pay only for what exceeds it. Prices below are Google's list prices for US regions at the time of writing; they differ by region and change, so check the Firestore, Cloud Run and Firebase pages.
| Resource | Free | Then |
|---|---|---|
| Firestore reads | 50,000 / day | $0.06 per 100,000 |
| Firestore writes | 20,000 / day | $0.18 per 100,000 |
| Firestore deletes | 20,000 / day | $0.02 per 100,000 |
| Firestore storage | 1 GiB | $0.18 per GiB-month |
| Function requests | 2,000,000 / month | $0.40 per 1,000,000 |
| Function CPU | 180,000 vCPU-seconds / month | $0.000024 per vCPU-second |
| Function memory | 360,000 GiB-seconds / month | $0.0000025 per GiB-second |
| Hosting transfer | 360 MB / day | $0.15 per GB |
| Secret Manager | 6 secret versions, 10,000 accesses / month | $0.06 per version, $0.03 per 10,000 |
Counted from the code in the template. npm test exercises every one of them.
| Operation | Function calls | Reads | Writes | Deletes |
|---|---|---|---|---|
| Load a page (HTML, JS, CSS) | 0 | 0 | 0 | 0 |
| Verify a session token, webhook or proxy signature | — | 0 | 0 | 0 |
GET /api/shop, product search, product detail | 1 | 0 or 1 ¹ | 0 | 0 |
| A shop's very first API call (token exchange) | 1 | 1 | 1 | 0 |
GET /api/settings | 1 | 1 | 0 | 0 |
POST /api/settings | 1 | 1 | 1 | 0 |
GET /api/keys | 1 | 1 per key, minimum 1 ² | 0 | 0 |
POST /api/keys | 1 | 1 per existing key, minimum 1 ² | 1 | 0 |
DELETE /api/keys/:id | 1 | 1 | 1 | 0 |
| A call made with an API key | 1 | 1 or 2 ¹ | 0 or 1 ³ | 0 |
Webhook app/uninstalled | 1 | 0 | 0 | 1 |
Webhook shop/redact | 1 | 0 | 0 | 2 |
Webhook customers/* | 1 | 0 | 0 | 0 |
| Legacy OAuth install | 2 | 1 | 2 | 1 |
Storefront GET /proxy/hello | 1 | 0 | 0 | 0 |
¹ The access token is cached in the instance for 5 minutes, so a busy shop costs
one read per 5 minutes per function instance, not one per request.
² A query that matches nothing is still billed one read.
³ lastUsedAt is written at most once a minute per key.
Function CPU runs out first, not Firestore and not the request count. A request that calls the Shopify Admin API spends most of its time waiting on Shopify, and that wait is billed.
Assume one active store makes 10 API calls a day, each billed 0.5 seconds at 1 vCPU and 256 MiB, and causes 3 reads and 0.2 writes:
| Resource | Free per day | One store-day | Stores per day |
|---|---|---|---|
| Function CPU | 6,000 vCPU-seconds | 5 vCPU-seconds | 1,200 |
| Function memory | 12,000 GiB-seconds | 1.25 GiB-seconds | 9,600 |
| Function requests | 66,666 | 10 | 6,666 |
| Firestore reads | 50,000 | 3 | 16,666 |
| Firestore writes | 20,000 | 0.2 | 100,000 |
About 1,200 daily active stores fit in the free tier under those assumptions. At a 20% daily active rate that is roughly 6,000 installed stores.
This is the conservative figure: it bills every request as if it had an instance to itself. Requests that overlap on one instance share its billed time, so real usage at volume is lower. Measure yours in Cloud Run's metrics and redo the sum.
One thousand requests of 0.5 seconds cost about $0.013: $0.012 of CPU, $0.0003 of memory, $0.0004 of request fee.
| Daily active stores | Requests / month | Functions | Firestore | Total / month |
|---|---|---|---|---|
| 1,200 | 360,000 | $0 | $0 | $0 |
| 5,000 | 1,500,000 | ~$14 | $0 | ~$14 |
| 10,000 | 3,000,000 | ~$32 | $0 | ~$32 |
| 50,000 | 15,000,000 | ~$185 | ~$2 | ~$187 |
| 100,000 | 30,000,000 | ~$375 | ~$5 | ~$380 |
Functions are the bill. Firestore stays near zero because verifying a request costs no reads and the access token is cached.
Not traffic. Work over a whole collection:
getAll() with ids that may not exist. It bills a read for every id handed
to it, found or not. A documentId() in [...] query bills only what exists.orders/create on a busy store is one function
call per order, plus whatever your handler reads and writes.Two things to know when you read the invoice:
maxInstances is the only hard ceiling you have.# Interactive (recommended)
npx @mksd0398/nitrogen
# With project name
npx @mksd0398/nitrogen my-app
# Skip the deploy steps (scaffold only)
npx @mksd0398/nitrogen my-app --no-deploy
# Auto-install any missing CLI tools without asking
npx @mksd0398/nitrogen my-app --yes
# Non-interactive (CI/CD, AI agents). The secret can be --api-secret instead.
SHOPIFY_API_SECRET="$(cat secret.txt)" npx @mksd0398/nitrogen my-app \
--api-key=abc123 \
--project-id=my-firebase-project \
--create-project \
--firestore-region=europe-west1 \
--scopes=read_products,write_products
# Help
npx @mksd0398/nitrogen --help
| Flag | |
|---|---|
--region=REGION | Region the functions run in. The wizard asks, offering the region next to the database first. In CI it follows the Firestore location. |
--firestore-region=LOC | Where to create Firestore. The wizard asks, with nothing preselected for you. Cannot be changed later. |
--shop=STORE | Make a single-tenant app locked to this store. The wizard asks if omitted; in CI the app is multi-tenant without it. |
--no-deploy | Scaffold only. |
--skip-shopify | Do not create or link a Shopify app. |
--skip-provision | Do not provision Firebase services. |
--overwrite | Non-interactive only: replace a project folder that already has files. Without it the run stops. The folder you run from is never replaced. |
--distribute | Open the distribution page for an existing app. |
Run without a terminal and without those flags, as an AI agent's shell does, the CLI stops with exit code 1 and names the flags it needs. It never waits on a question.
The region is written to two places that must agree: APP_REGION in
functions/.env, and every rewrite in firebase.json.
cd functions
npm test
Runs against an in-memory Firestore with fetch stubbed: no emulator, no network,
no credentials. It covers every verifier, the routing, tenant isolation, API keys,
rate limits, and that index and firebase.json agree. TypeScript projects are
built first and tested as compiled.
cd functions
npm run serve
# Functions → http://localhost:5001/<project>/<region>/<function>/<path>
# Firestore → http://localhost:8080
The emulator reads the client secret from functions/.secret.local.
firebase deploy --force # Everything
firebase deploy --only functions # All functions
firebase deploy --only functions:products # One function
firebase deploy --only hosting # Frontend and rewrites
TypeScript projects build automatically before a functions deploy.
Verify a deploy. A partly failed deploy uploads Hosting but never releases it: functions report success while every URL serves the previous version. Compare a hash of a live asset with your source:
curl -s https://YOUR-PROJECT.web.app/js/app.js | tr -d '\r' | sha256sum
tr -d '\r' < web/js/app.js | sha256sum
firebase functions:secrets:set SHOPIFY_API_SECRET
firebase deploy --only functions
See Writing an API.
Add a route to functions/src/proxy.ts and enable [app_proxy] in shopify.app.toml:
"GET /proxy/my-route": async (ctx) => ({ hello: ctx.shop }),
Register the topic in shopify.app.toml, then add a case in functions/src/webhooks.ts:
case "orders/create": {
const order = body;
// Your logic. 5 seconds. Queue anything slow.
break;
}
export default endpoint(routes, { apiKey: "read" });
Merchants create keys on the API keys page. Callers send X-API-Key: key_live_…
and act as the shop that created the key. Add scopes to SCOPES in api-keys.ts.
Keep anything that changes data session-only unless you have decided otherwise.
import { tenantId } from "../config";
import { db } from "../firebase";
await db.collection("myData").doc(tenantId(ctx.app, ctx.shop)).set({ key: "value" });
Key tenant data by tenantId(), and add the collection to the shop/redact
handler in webhooks.ts so it is deleted when Shopify asks.
Two pages of your app are a reference you can copy from:
| Page | Covers |
|---|---|
Components (/polaris) | All 50 Polaris web components: actions, feedback, forms, layout, media, overlays and typography. Each has a live example and its HTML. |
App Bridge (/apis) | All 26 App Bridge APIs and elements: toast, modal, resource picker, scopes, intents, save bar, POS and the rest. Each has a Run button that executes the code shown. |
They are ordinary pages in web/. Delete them, and their links in
<s-app-nav>, once you no longer need them.
The pages load polaris-1.js, Shopify's stable channel: it follows the newest
Polaris 1 release, so fixes reach your app without a change. To freeze a
version, name it in the script tag, for example polaris-1.1.js.
Copy web/keys.html and web/js/pages/keys.js, and add the link to the
<s-app-nav> of every page.
Use the appSubscriptionCreate mutation through graphql(ctx, …).
import { onSchedule } from "firebase-functions/v2/scheduler";
export const dailyCleanup = onSchedule("every 24 hours", async () => {
// ...
});
Expired OAuth nonces need no cleanup job: authNonces.expiresAt is a Timestamp,
so a Firestore TTL policy on that
field purges them.
All three mandatory webhooks are subscribed and verified:
| Webhook | Purpose | Status |
|---|---|---|
customers/data_request | Export customer data | Handler included — add your logic if you store customer data |
customers/redact | Delete customer data | Handler included — add your logic if you store customer data |
shop/redact | Delete all shop data | Implemented for shopSessions and appSettings — add your own collections |
These are required for Shopify App Store listing.
| Problem | Solution |
|---|---|
Every function answers 429 with the plain text "Rate exceeded." | Cloud Run is refusing to start instances, so your code never ran (the app's own 429 is JSON). If billing was just enabled, wait for it to apply: firebase functions:log says "billing is disabled for this project" until it has |
| Every API call answers 401 | The client secret in Secret Manager does not match the app. firebase functions:secrets:set SHOPIFY_API_SECRET, then redeploy functions |
| Webhooks answer 401 | Same cause: the HMAC is verified with that secret |
| A route answers 404 from Hosting | The rewrite's region or serviceId does not match the function. Run npm test in functions/ |
| A route answers Google's 403 page | The function lost invoker: "public" |
| Deploy fails naming a secret | The secret does not exist yet. Create it, then deploy |
firebase deploy exits 1 on a new project | Use firebase deploy --force — a bare deploy stalls on the artifact-policy prompt and leaves Hosting unreleased |
| Deploy times out analyzing functions | Set FUNCTIONS_DISCOVERY_TIMEOUT=120 before deploying |
| The deploy succeeded but the site is unchanged | Hosting was uploaded but not released. Hash-check (see Deploy) and redeploy |
| 429 from your own API | The per-caller limit. See Rate Limits |
| Firestore create fails with 403 | The Firestore API is not enabled yet: gcloud services enable firestore.googleapis.com --project=YOUR_ID |
App name cannot contain "Shopify" | Shopify rejects those names — pick another (the CLI strips it automatically) |
shopify app deploy says "not a member of the organization" | The app was created under a different Shopify account. Run shopify auth login |
Contributions welcome! Please open an issue or PR.
git clone https://github.com/mksd0398/nitrogen.git
cd nitrogen
npm install
npm test # scaffolds both templates and checks the output
npm run test:e2e # also installs, builds and tests each scaffold
npm link # Test locally: nitrogen test-app
The JavaScript and TypeScript templates must stay in step: npm test fails if
their route tables differ.
MIT
| "App Engine" on the bill |
| That is Firestore. See What It Costs |